HarmonyOS APP开发:3D交互与手势控制

举报
Jack20 发表于 2026/06/22 21:24:16 2026/06/22
【摘要】 HarmonyOS APP开发:3D交互与手势控制📌 核心要点:从射线拾取原理到3D旋转/缩放/平移手势的完整实现,掌握3D物体选择、高亮反馈与交互状态管理,打造一个可交互的3D产品展示器。 一、背景与动机你有没有用过那种3D产品展示页面?比如看一双球鞋,手指一划就能360度旋转,双指一捏就能放大看细节,点一下鞋面还能高亮选中。这种交互体验,比看2D图片强太多了。但3D交互的难点在于——...

HarmonyOS APP开发:3D交互与手势控制

📌 核心要点:从射线拾取原理到3D旋转/缩放/平移手势的完整实现,掌握3D物体选择、高亮反馈与交互状态管理,打造一个可交互的3D产品展示器。


一、背景与动机

你有没有用过那种3D产品展示页面?比如看一双球鞋,手指一划就能360度旋转,双指一捏就能放大看细节,点一下鞋面还能高亮选中。这种交互体验,比看2D图片强太多了。

但3D交互的难点在于——屏幕是2D的,物体是3D的,怎么把2D的手势映射到3D空间?

这就是3D交互要解决的核心问题。在2D界面里,点击一个按钮,坐标是一一对应的。但在3D场景里,你手指触碰的屏幕坐标,对应的是一条从相机出发的射线,这条射线可能穿过多个3D物体,也可能什么都没碰到。怎么判断射线和哪个物体相交?怎么让手指拖动映射到3D旋转?怎么区分旋转、缩放、平移三种手势?这些都是3D交互必须回答的问题。

在HarmonyOS上,系统提供了丰富的手势识别API(TapGesture、PinchGesture、RotationGesture等),但它们都是2D层面的。要把这些手势和3D场景关联起来,需要我们自己实现"2D手势→3D变换"的映射逻辑。今天就来彻底搞懂这套逻辑。


二、核心原理

2.1 射线拾取(Ray Casting)

射线拾取是3D交互的基石。它的核心思路是:把屏幕上的2D触摸点,转换成3D空间中的一条射线,然后检测这条射线与场景中哪些物体相交。

graph TD
    A[屏幕触摸点<br/>screenX, screenY]:::primary --> B[NDC坐标转换<br/>x=2*sx/w-1<br/>y=1-2*sy/h]:::info
    B --> C[逆投影变换<br/>射线方向=inverseProj*NDC]:::warning
    C --> D[世界空间射线<br/>origin=cameraPos<br/>dir=inverseView*rayDir]:::success
    D --> E{射线-物体相交检测}:::warning
    E -->|命中| F[返回最近交点<br/>物体+距离+法线]:::success
    E -->|未命中| G[无选中物体]:::error

    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
    classDef success fill:#9C27B0,stroke:#7B1FA2,color:#fff

2.2 射线-三角形相交检测

3D物体的表面由三角形网格组成,射线拾取的本质就是检测射线与每个三角形的相交情况。最经典的算法是Möller-Trumbore算法,它通过计算射线与三角形所在平面的交点,然后判断交点是否在三角形内部。

2.3 3D手势映射

手势 2D输入 3D效果 映射方式
单指拖动 屏幕XY位移 绕Y/X轴旋转 水平位移→Y轴旋转,垂直位移→X轴旋转
双指捏合 两指距离变化 缩放 距离增大→放大,距离减小→缩小
双指旋转 两指连线角度 Z轴旋转 角度变化→绕Z轴旋转
单指快速点击 屏幕坐标 选择物体 射线拾取

2.4 交互状态机

3D交互需要管理多种状态,避免手势冲突:

IDLEDRAG_ROTATE(单指拖动)
IDLEPINCH_SCALE(双指捏合)
IDLETAP_SELECT(点击选择)
DRAG_ROTATEIDLE(手指抬起)
PINCH_SCALEIDLE(手指抬起)

三、代码实战

3.1 基础用法:射线生成与相交检测

// 3D数学工具
export class Math3D {
  // 从屏幕坐标生成世界空间射线
  static screenToRay(
    screenX: number, screenY: number,
    screenWidth: number, screenHeight: number,
    viewMatrix: Float32Array,
    projectionMatrix: Float32Array
  ): { origin: Float32Array; direction: Float32Array } {
    // 1. 屏幕坐标转NDC(归一化设备坐标)
    const ndcX = (2.0 * screenX) / screenWidth - 1.0;
    const ndcY = 1.0 - (2.0 * screenY) / screenHeight;
    const ndcZ = -1.0; // 近平面

    // 2. NDC坐标转视图空间(逆投影)
    const invProj = Math3D.invertMatrix4(projectionMatrix);
    const rayDirView = Math3D.multiplyMat4Vec4(invProj, new Float32Array([ndcX, ndcY, ndcZ, 1.0]));

    // 透视除法
    rayDirView[0] /= rayDirView[3];
    rayDirView[1] /= rayDirView[3];
    rayDirView[2] /= rayDirView[3];
    rayDirView[3] = 1.0;

    // 射线方向(视图空间中从原点出发)
    const rayDir = new Float32Array([rayDirView[0], rayDirView[1], rayDirView[2]]);
    Math3D.normalizeVec3(rayDir);

    // 3. 视图空间转世界空间(逆视图矩阵)
    const invView = Math3D.invertMatrix4(viewMatrix);

    // 射线原点 = 相机位置 = 视图矩阵逆矩阵的平移分量
    const rayOrigin = new Float32Array([invView[12], invView[13], invView[14]]);

    // 射线方向 = 逆视图矩阵 * 视图空间方向
    const rayDirWorld = Math3D.multiplyMat3Vec3(invView, rayDir);
    Math3D.normalizeVec3(rayDirWorld);

    return { origin: rayOrigin, direction: rayDirWorld };
  }

  // Möller-Trumbore射线-三角形相交检测
  static rayTriangleIntersect(
    rayOrigin: Float32Array,
    rayDir: Float32Array,
    v0: Float32Array, v1: Float32Array, v2: Float32Array,
    maxDistance: number = Infinity
  ): number {
    const EPSILON = 1e-7;

    // 计算三角形的两条边
    const edge1 = Math3D.subVec3(v1, v0);
    const edge2 = Math3D.subVec3(v2, v0);

    // h = rayDir × edge2
    const h = Math3D.crossVec3(rayDir, edge2);
    const a = Math3D.dotVec3(edge1, h);

    // 射线与三角形平行(不相交)
    if (Math.abs(a) < EPSILON) return -1;

    const f = 1.0 / a;
    const s = Math3D.subVec3(rayOrigin, v0);
    const u = f * Math3D.dotVec3(s, h);

    // u超出范围
    if (u < 0.0 || u > 1.0) return -1;

    const q = Math3D.crossVec3(s, edge1);
    const v = f * Math3D.dotVec3(rayDir, q);

    // v超出范围或u+v>1
    if (v < 0.0 || u + v > 1.0) return -1;

    // 计算交点距离
    const t = f * Math3D.dotVec3(edge2, q);

    if (t > EPSILON && t < maxDistance) {
      return t; // 返回交点距离
    }

    return -1; // 不相交
  }

  // 射线-AABB包围盒相交检测(快速预筛选)
  static rayAABBIntersect(
    rayOrigin: Float32Array,
    rayDir: Float32Array,
    boxMin: Float32Array,
    boxMax: Float32Array
  ): boolean {
    let tMin = -Infinity;
    let tMax = Infinity;

    for (let i = 0; i < 3; i++) {
      if (Math.abs(rayDir[i]) < 1e-7) {
        // 射线与该轴平行,检查原点是否在包围盒内
        if (rayOrigin[i] < boxMin[i] || rayOrigin[i] > boxMax[i]) {
          return false;
        }
      } else {
        const t1 = (boxMin[i] - rayOrigin[i]) / rayDir[i];
        const t2 = (boxMax[i] - rayOrigin[i]) / rayDir[i];
        const near = Math.min(t1, t2);
        const far = Math.max(t1, t2);
        tMin = Math.max(tMin, near);
        tMax = Math.min(tMax, far);
        if (tMin > tMax) return false;
      }
    }

    return tMax >= 0;
  }

  // === 向量运算工具 ===
  static subVec3(a: Float32Array, b: Float32Array): Float32Array {
    return new Float32Array([a[0] - b[0], a[1] - b[1], a[2] - b[2]]);
  }

  static dotVec3(a: Float32Array, b: Float32Array): number {
    return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
  }

  static crossVec3(a: Float32Array, b: Float32Array): Float32Array {
    return new Float32Array([
      a[1] * b[2] - a[2] * b[1],
      a[2] * b[0] - a[0] * b[2],
      a[0] * b[1] - a[1] * b[0]
    ]);
  }

  static normalizeVec3(v: Float32Array): void {
    const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
    if (len > 0) {
      v[0] /= len; v[1] /= len; v[2] /= len;
    }
  }

  static multiplyMat3Vec3(m: Float32Array, v: Float32Array): Float32Array {
    return new Float32Array([
      m[0] * v[0] + m[4] * v[1] + m[8] * v[2],
      m[1] * v[0] + m[5] * v[1] + m[9] * v[2],
      m[2] * v[0] + m[6] * v[1] + m[10] * v[2]
    ]);
  }

  static multiplyMat4Vec4(m: Float32Array, v: Float32Array): Float32Array {
    return new Float32Array([
      m[0]*v[0] + m[4]*v[1] + m[8]*v[2] + m[12]*v[3],
      m[1]*v[0] + m[5]*v[1] + m[9]*v[2] + m[13]*v[3],
      m[2]*v[0] + m[6]*v[1] + m[10]*v[2] + m[14]*v[3],
      m[3]*v[0] + m[7]*v[1] + m[11]*v[2] + m[15]*v[3]
    ]);
  }

  static invertMatrix4(m: Float32Array): Float32Array {
    // 4x4矩阵求逆(使用伴随矩阵法)
    const inv = new Float32Array(16);
    // ... 完整的矩阵求逆实现 ...
    // 简化版:使用余子式展开
    inv[0] = m[5]*m[10]*m[15] - m[5]*m[11]*m[14] - m[9]*m[6]*m[15] + m[9]*m[7]*m[14] + m[13]*m[6]*m[11] - m[13]*m[7]*m[10];
    inv[4] = -m[4]*m[10]*m[15] + m[4]*m[11]*m[14] + m[8]*m[6]*m[15] - m[8]*m[7]*m[14] - m[12]*m[6]*m[11] + m[12]*m[7]*m[10];
    inv[8] = m[4]*m[9]*m[15] - m[4]*m[11]*m[13] - m[8]*m[5]*m[15] + m[8]*m[7]*m[13] + m[12]*m[5]*m[11] - m[12]*m[7]*m[9];
    inv[12] = -m[4]*m[9]*m[14] + m[4]*m[10]*m[13] + m[8]*m[5]*m[14] - m[8]*m[6]*m[13] - m[12]*m[5]*m[10] + m[12]*m[6]*m[9];
    inv[1] = -m[1]*m[10]*m[15] + m[1]*m[11]*m[14] + m[9]*m[2]*m[15] - m[9]*m[3]*m[14] - m[13]*m[2]*m[11] + m[13]*m[3]*m[10];
    inv[5] = m[0]*m[10]*m[15] - m[0]*m[11]*m[14] - m[8]*m[2]*m[15] + m[8]*m[3]*m[14] + m[12]*m[2]*m[11] - m[12]*m[3]*m[10];
    inv[9] = -m[0]*m[9]*m[15] + m[0]*m[11]*m[13] + m[8]*m[1]*m[15] - m[8]*m[3]*m[13] - m[12]*m[1]*m[11] + m[12]*m[3]*m[9];
    inv[13] = m[0]*m[9]*m[14] - m[0]*m[10]*m[13] - m[8]*m[1]*m[14] + m[8]*m[2]*m[13] + m[12]*m[1]*m[10] - m[12]*m[2]*m[9];
    inv[2] = m[1]*m[6]*m[15] - m[1]*m[7]*m[14] - m[5]*m[2]*m[15] + m[5]*m[3]*m[14] + m[13]*m[2]*m[7] - m[13]*m[3]*m[6];
    inv[6] = -m[0]*m[6]*m[15] + m[0]*m[7]*m[14] + m[4]*m[2]*m[15] - m[4]*m[3]*m[14] - m[12]*m[2]*m[7] + m[12]*m[3]*m[6];
    inv[10] = m[0]*m[5]*m[15] - m[0]*m[7]*m[13] - m[4]*m[1]*m[15] + m[4]*m[3]*m[13] + m[12]*m[1]*m[7] - m[12]*m[3]*m[5];
    inv[14] = -m[0]*m[5]*m[14] + m[0]*m[6]*m[13] + m[4]*m[1]*m[14] - m[4]*m[2]*m[13] - m[12]*m[1]*m[6] + m[12]*m[2]*m[5];
    inv[3] = -m[1]*m[6]*m[11] + m[1]*m[7]*m[10] + m[5]*m[2]*m[11] - m[5]*m[3]*m[10] - m[9]*m[2]*m[7] + m[9]*m[3]*m[6];
    inv[7] = m[0]*m[6]*m[11] - m[0]*m[7]*m[10] - m[4]*m[2]*m[11] + m[4]*m[3]*m[10] + m[8]*m[2]*m[7] - m[8]*m[3]*m[6];
    inv[11] = -m[0]*m[5]*m[11] + m[0]*m[7]*m[9] + m[4]*m[1]*m[11] - m[4]*m[3]*m[9] - m[8]*m[1]*m[7] + m[8]*m[3]*m[5];
    inv[15] = m[0]*m[5]*m[10] - m[0]*m[6]*m[9] - m[4]*m[1]*m[10] + m[4]*m[2]*m[9] + m[8]*m[1]*m[6] - m[8]*m[2]*m[5];

    // 计算行列式
    let det = m[0]*inv[0] + m[1]*inv[4] + m[2]*inv[8] + m[3]*inv[12];
    if (Math.abs(det) < 1e-7) return new Float32Array(16); // 不可逆

    det = 1.0 / det;
    for (let i = 0; i < 16; i++) inv[i] *= det;
    return inv;
  }
}

3.2 进阶用法:3D手势控制器

将HarmonyOS的手势API与3D变换逻辑结合:

// 3D交互状态
enum InteractionState {
  IDLE,
  ROTATING,
  SCALING,
  TRANSLATING,
  SELECTING
}

// 3D手势控制器
export class Gesture3DController {
  private state: InteractionState = InteractionState.IDLE;
  // 旋转参数
  private rotationX: number = 0;
  private rotationY: number = 0;
  // 缩放参数
  private scale: number = 1.0;
  // 平移参数
  private translateX: number = 0;
  private translateY: number = 0;
  // 上次触摸位置
  private lastX: number = 0;
  private lastY: number = 0;
  // 上次双指距离
  private lastPinchDistance: number = 0;
  // 选中物体
  private selectedObjectId: string = '';

  // 旋转灵敏度
  private rotateSensitivity: number = 0.5;
  // 缩放灵敏度
  private scaleSensitivity: number = 0.005;
  // 缩放范围
  private minScale: number = 0.3;
  private maxScale: number = 5.0;

  // 处理单指拖动(旋转)
  handlePan(dx: number, dy: number): void {
    if (this.state === InteractionState.ROTATING) {
      // 水平位移映射为Y轴旋转,垂直位移映射为X轴旋转
      this.rotationY += dx * this.rotateSensitivity;
      this.rotationX += dy * this.rotateSensitivity;

      // 限制X轴旋转范围(防止翻转)
      this.rotationX = Math.max(-89, Math.min(89, this.rotationX));
    } else if (this.state === InteractionState.TRANSLATING) {
      this.translateX += dx * 0.5;
      this.translateY += dy * 0.5;
    }
  }

  // 处理双指捏合(缩放)
  handlePinch(scaleFactor: number): void {
    if (this.state === InteractionState.SCALING) {
      this.scale *= scaleFactor;
      this.scale = Math.max(this.minScale, Math.min(this.maxScale, this.scale));
    }
  }

  // 处理点击(选择)
  handleTap(screenX: number, screenY: number,
            screenWidth: number, screenHeight: number,
            viewMatrix: Float32Array, projectionMatrix: Float32Array,
            objects: InteractableObject[]): string {
    // 生成射线
    const ray = Math3D.screenToRay(screenX, screenY, screenWidth, screenHeight,
      viewMatrix, projectionMatrix);

    // 遍历所有可交互物体,找到最近的交点
    let closestId = '';
    let closestDist = Infinity;

    for (const obj of objects) {
      // 先用AABB快速筛选
      if (!Math3D.rayAABBIntersect(ray.origin, ray.direction, obj.aabbMin, obj.aabbMax)) {
        continue;
      }

      // 再用三角形精确检测
      for (let i = 0; i < obj.triangles.length; i += 3) {
        const dist = Math3D.rayTriangleIntersect(
          ray.origin, ray.direction,
          obj.triangles[i], obj.triangles[i + 1], obj.triangles[i + 2],
          closestDist
        );

        if (dist > 0 && dist < closestDist) {
          closestDist = dist;
          closestId = obj.id;
        }
      }
    }

    this.selectedObjectId = closestId;
    return closestId;
  }

  // 获取当前变换矩阵
  getTransformMatrix(): Float32Array {
    // 构建模型矩阵:T * R * S
    const mat = new Float32Array(16);

    // 简化:先缩放,再旋转,再平移
    const cosX = Math.cos(this.rotationX * Math.PI / 180);
    const sinX = Math.sin(this.rotationX * Math.PI / 180);
    const cosY = Math.cos(this.rotationY * Math.PI / 180);
    const sinY = Math.sin(this.rotationY * Math.PI / 180);

    // 组合旋转矩阵(Y轴旋转 * X轴旋转)并乘以缩放
    mat[0] = cosY * this.scale;
    mat[1] = sinX * sinY * this.scale;
    mat[2] = -cosX * sinY * this.scale;
    mat[3] = 0;
    mat[4] = 0;
    mat[5] = cosX * this.scale;
    mat[6] = sinX * this.scale;
    mat[7] = 0;
    mat[8] = sinY * this.scale;
    mat[9] = -sinX * cosY * this.scale;
    mat[10] = cosX * cosY * this.scale;
    mat[11] = 0;
    mat[12] = this.translateX;
    mat[13] = this.translateY;
    mat[14] = 0;
    mat[15] = 1;

    return mat;
  }

  // 状态切换
  setState(state: InteractionState): void {
    this.state = state;
  }

  getState(): InteractionState {
    return this.state;
  }

  getSelectedObjectId(): string {
    return this.selectedObjectId;
  }

  // 重置变换
  reset(): void {
    this.rotationX = 0;
    this.rotationY = 0;
    this.scale = 1.0;
    this.translateX = 0;
    this.translateY = 0;
    this.selectedObjectId = '';
  }
}

// 可交互物体接口
export interface InteractableObject {
  id: string;
  aabbMin: Float32Array;     // 包围盒最小点
  aabbMax: Float32Array;     // 包围盒最大点
  triangles: Float32Array[]; // 三角形顶点数组(每3个一组)
  highlightColor: string;    // 高亮颜色
}

3.3 完整示例:3D产品展示器

将手势控制器与UI结合,实现一个完整的3D产品展示器:

import { Gesture3DController, InteractionState, InteractableObject, Math3D } from './Gesture3DController'

@Entry
@Component
struct ProductViewerPage {
  // 手势控制器
  private gestureController: Gesture3DController = new Gesture3DController()
  // 渲染相关
  private xComponentController: XComponentController = new XComponentController()
  // 当前选中物体
  @State selectedPart: string = '未选中'
  // 当前缩放
  @State currentScale: number = 1.0
  // 当前旋转角度
  @State rotationInfo: string = '0°, 0°'
  // 交互模式
  @State interactionMode: string = '旋转'
  // 产品部件列表
  @State parts: string[] = ['鞋面', '鞋底', '鞋带', '鞋舌', '后跟']
  // 自动旋转
  @State autoRotate: boolean = true

  aboutToAppear() {
    this.startAutoRotate()
  }

  // 自动旋转动画
  private startAutoRotate(): void {
    setInterval(() => {
      if (this.autoRotate && this.gestureController.getState() === InteractionState.IDLE) {
        // 自动绕Y轴缓慢旋转
        this.gestureController.handlePan(0.5, 0)
      }
    }, 16)
  }

  build() {
    Column() {
      // 顶部信息栏
      Row() {
        Text('👟 3D球鞋展示')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Blank()
        Text(`选中: ${this.selectedPart}`)
          .fontSize(12)
          .fontColor('#6C63FF')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 8 })
      .backgroundColor('#1A1A2E')

      // 3D渲染区域
      Stack() {
        XComponent({
          id: 'productViewer',
          type: XComponentType.SURFACE,
          libraryname: 'product_render',
          controller: this.xComponentController
        })
          .width('100%')
          .height(350)
          .backgroundColor('#0A0A1A')

        // 交互提示
        Text('👆 单指旋转 | 🤏 双指缩放 | 👆 点击选择')
          .fontSize(11)
          .fontColor('#666688')
          .position({ x: '50%', y: 16 })
          .translate({ x: '-50%' })
      }

      // 手势覆盖层
      Stack() {
        // 单指拖动 → 旋转
        GestureGroup(GestureMode.Exclusive,
          // 拖动手势(旋转)
          PanGesture({ fingers: 1, distance: 5 })
            .onActionStart(() => {
              this.gestureController.setState(InteractionState.ROTATING)
              this.autoRotate = false
            })
            .onActionUpdate((event: GestureEvent) => {
              this.gestureController.handlePan(event.offsetX - (this.lastOffsetX ?? 0),
                event.offsetY - (this.lastOffsetY ?? 0))
              this.lastOffsetX = event.offsetX
              this.lastOffsetY = event.offsetY
              this.updateInfo()
            })
            .onActionEnd(() => {
              this.gestureController.setState(InteractionState.IDLE)
              this.lastOffsetX = 0
              this.lastOffsetY = 0
            }),

          // 捏合手势(缩放)
          PinchGesture({ fingers: 2, distance: 5 })
            .onActionStart(() => {
              this.gestureController.setState(InteractionState.SCALING)
              this.autoRotate = false
            })
            .onActionUpdate((event: GestureEvent) => {
              this.gestureController.handlePinch(event.scale)
              this.currentScale = this.gestureController.getScale()
            })
            .onActionEnd(() => {
              this.gestureController.setState(InteractionState.IDLE)
            }),

          // 点击手势(选择)
          TapGesture({ count: 1 })
            .onAction((event: GestureEvent) => {
              this.gestureController.setState(InteractionState.SELECTING)
              // 执行射线拾取
              const selectedId = this.gestureController.handleTap(
                event.fingerList[0].globalX, event.fingerList[0].globalY,
                360, 350,
                this.getViewMatrix(), this.getProjectionMatrix(),
                this.getInteractableObjects()
              )
              this.selectedPart = selectedId || '未选中'
              this.gestureController.setState(InteractionState.IDLE)
            })
        )
      }
      .width('100%')
      .height(350)
      .position({ x: 0, y: 52 })

      // 控制面板
      Column() {
        // 模式切换
        Row() {
          Text('交互模式:')
            .fontSize(13)
            .fontColor('#CCCCCC')
            .margin({ right: 8 })
          ForEach(['旋转', '平移'], (mode: string) => {
            Button(mode)
              .fontSize(12)
              .height(28)
              .backgroundColor(this.interactionMode === mode ? '#6C63FF' : '#333355')
              .onClick(() => {
                this.interactionMode = mode
                this.gestureController.setState(mode === '旋转'
                  ? InteractionState.ROTATING : InteractionState.TRANSLATING)
              })
          }, (mode: string) => mode)
        }
        .margin({ bottom: 12 })

        // 部件列表(可点击高亮)
        Row() {
          Text('部件选择:')
            .fontSize(13)
            .fontColor('#CCCCCC')
            .margin({ right: 8 })
          Scroll() {
            Row() {
              ForEach(this.parts, (part: string) => {
                Button(part)
                  .fontSize(11)
                  .height(26)
                  .backgroundColor(this.selectedPart === part ? '#6C63FF' : '#333355')
                  .onClick(() => {
                    this.selectedPart = part
                    // 通知渲染层高亮对应部件
                  })
              }, (part: string) => part)
            }
            .space(4)
          }
          .scrollable(ScrollDirection.Horizontal)
          .layoutWeight(1)
        }
        .margin({ bottom: 12 })

        // 信息显示
        Row() {
          Column() {
            Text('缩放')
              .fontSize(11)
              .fontColor('#888888')
            Text(`${this.currentScale.toFixed(2)}x`)
              .fontSize(14)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1)

          Column() {
            Text('旋转')
              .fontSize(11)
              .fontColor('#888888')
            Text(this.rotationInfo)
              .fontSize(14)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1)

          Column() {
            Text('选中')
              .fontSize(11)
              .fontColor('#888888')
            Text(this.selectedPart)
              .fontSize(14)
              .fontColor('#6C63FF')
              .fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1)
        }
        .margin({ bottom: 12 })

        // 底部按钮
        Row() {
          Button('🔄 重置视角')
            .fontSize(12)
            .height(36)
            .backgroundColor('#2196F3')
            .layoutWeight(1)
            .onClick(() => {
              this.gestureController.reset()
              this.selectedPart = '未选中'
              this.currentScale = 1.0
              this.rotationInfo = '0°, 0°'
              this.autoRotate = true
            })

          Button(`${this.autoRotate ? '⏸ 暂停' : '▶ 播放'}自动旋转`)
            .fontSize(12)
            .height(36)
            .backgroundColor(this.autoRotate ? '#FF9800' : '#4CAF50')
            .layoutWeight(1)
            .onClick(() => {
              this.autoRotate = !this.autoRotate
            })
        }
        .space(8)
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#16213E')
      .borderRadius({ topLeft: 16, topRight: 16 })

      Blank()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0F0F23')
  }

  // 辅助变量(手势偏移记录)
  private lastOffsetX: number = 0
  private lastOffsetY: number = 0

  // 更新信息显示
  private updateInfo(): void {
    const mat = this.gestureController.getTransformMatrix()
    // 从矩阵提取旋转角度(简化)
    this.rotationInfo = `${(this.gestureController.getRotationY() % 360).toFixed(0)}°, ${this.gestureController.getRotationX().toFixed(0)}°`
    this.currentScale = this.gestureController.getScale()
  }

  // 以下方法在实际项目中由渲染层提供
  private getViewMatrix(): Float32Array { return new Float32Array(16) }
  private getProjectionMatrix(): Float32Array { return new Float32Array(16) }
  private getInteractableObjects(): InteractableObject[] { return [] }
}

3.4 3D物体高亮效果

选中物体后需要提供视觉反馈,以下是着色器实现的高亮效果:

// 带高亮效果的片段着色器
#version 300 es
precision highp float;

in vec3 vNormal;
in vec2 vTexCoord;
in vec3 vFragPos;

uniform vec3 uBaseColor;
uniform sampler2D uTexture;
uniform bool uHighlighted;       // 是否高亮
uniform vec3 uHighlightColor;    // 高亮颜色
uniform float uHighlightPulse;   // 高亮脉冲(0~1动态变化)

out vec4 fragColor;

void main() {
    vec4 texColor = texture(uTexture, vTexCoord);
    vec3 color = uBaseColor * texColor.rgb;

    if (uHighlighted) {
        // 高亮效果:叠加高亮颜色 + 脉冲呼吸
        float pulse = 0.5 + 0.5 * sin(uHighlightPulse * 6.28318);
        float highlightStrength = 0.3 + 0.2 * pulse;
        color = mix(color, uHighlightColor, highlightStrength);

        // 边缘发光(菲涅尔效应)
        vec3 viewDir = normalize(uViewPos - vFragPos);
        vec3 norm = normalize(vNormal);
        float fresnel = pow(1.0 - max(dot(viewDir, norm), 0.0), 3.0);
        color += uHighlightColor * fresnel * 0.5;
    }

    fragColor = vec4(color, texColor.a);
}

3.5 交互状态管理器

更完善的交互状态管理,支持手势优先级和冲突处理:

// 交互事件类型
export type InteractionEventType = 'tap' | 'pan_start' | 'pan_move' | 'pan_end'
  | 'pinch_start' | 'pinch_move' | 'pinch_end' | 'rotate_start' | 'rotate_move' | 'rotate_end'

// 交互事件
export interface InteractionEvent {
  type: InteractionEventType;
  // 触摸点信息
  pointers: PointerInfo[];
  // 缩放因子
  scaleFactor: number;
  // 旋转角度
  rotationAngle: number;
  // 偏移量
  offsetX: number;
  offsetY: number;
}

export interface PointerInfo {
  id: number;
  x: number;
  y: number;
}

// 交互状态管理器
export class InteractionStateManager {
  private currentState: InteractionState = InteractionState.IDLE
  private previousState: InteractionState = InteractionState.IDLE
  private stateStartTime: number = 0
  private onStateChange?: (from: InteractionState, to: InteractionState) => void

  // 手势优先级:缩放 > 旋转 > 平移 > 选择
  private static PRIORITY: Map<InteractionState, number> = new Map([
    [InteractionState.SCALING, 4],
    [InteractionState.ROTATING, 3],
    [InteractionState.TRANSLATING, 2],
    [InteractionState.SELECTING, 1],
    [InteractionState.IDLE, 0]
  ])

  // 处理交互事件
  handleEvent(event: InteractionEvent): void {
    const newState = this.resolveState(event)

    if (newState !== this.currentState) {
      // 检查优先级:只有新状态优先级更高才切换
      const currentPriority = InteractionStateManager.PRIORITY.get(this.currentState) ?? 0
      const newPriority = InteractionStateManager.PRIORITY.get(newState) ?? 0

      if (newPriority >= currentPriority || newState === InteractionState.IDLE) {
        this.transitionTo(newState)
      }
    }
  }

  // 根据事件类型解析目标状态
  private resolveState(event: InteractionEvent): InteractionState {
    switch (event.type) {
      case 'tap':
        return InteractionState.SELECTING
      case 'pan_start':
        return InteractionState.ROTATING
      case 'pinch_start':
        return InteractionState.SCALING
      case 'rotate_start':
        return InteractionState.ROTATING
      case 'pan_end':
      case 'pinch_end':
      case 'rotate_end':
        return InteractionState.IDLE
      default:
        return this.currentState
    }
  }

  // 状态切换
  private transitionTo(newState: InteractionState): void {
    this.previousState = this.currentState
    this.currentState = newState
    this.stateStartTime = Date.now()

    if (this.onStateChange) {
      this.onStateChange(this.previousState, this.currentState)
    }
  }

  // 设置状态变更回调
  setOnStateChange(callback: (from: InteractionState, to: InteractionState) => void): void {
    this.onStateChange = callback
  }

  getCurrentState(): InteractionState {
    return this.currentState
  }

  isIdle(): boolean {
    return this.currentState === InteractionState.IDLE
  }

  // 获取当前状态持续时间(毫秒)
  getStateDuration(): number {
    return Date.now() - this.stateStartTime
  }
}

四、踩坑与注意事项

1. PanGesture的offsetX/offsetY是累计值而非增量值

HarmonyOS的PanGesture.onActionUpdate中,event.offsetXevent.offsetY是从手势开始到当前的累计偏移量,不是每帧的增量。如果你直接用累计值乘以灵敏度,旋转速度会越来越快。必须用当前帧的offset减去上一帧的offset来计算增量。

2. 手势互斥与优先级

GestureGroup(GestureMode.Exclusive)中的手势是互斥的,第一个识别成功的手势会"吃掉"后续手势。但问题是:单指拖动和单指点击的起始动作相同(都是单指按下),系统可能先识别到PanGesture,导致TapGesture永远不触发。解决方案:使用GestureMode.Parallel让手势并行识别,然后在代码层根据移动距离判断是点击还是拖动。

3. 射线拾取的性能问题

如果场景中有成千上万个三角形,逐个做射线-三角形相交检测会非常慢。必须使用AABB包围盒做预筛选,只有射线穿过包围盒的物体才做精确检测。更进一步的优化是使用BVH(层次包围盒)或Octree(八叉树)加速结构。

4. 触摸坐标与XComponent坐标的映射

XComponent的触摸坐标是相对于组件自身的,如果XComponent不是全屏的,需要减去组件在屏幕上的偏移。否则射线方向会偏移,拾取结果不准确。建议使用event.fingerList[0].localX而非globalX来获取相对坐标。

5. 3D旋转的万向节锁问题

使用欧拉角(rotationX, rotationY)控制3D旋转时,当X轴旋转到±90°时,Y轴和Z轴的旋转会重合,导致"万向节锁"——某个方向突然无法旋转。解决方案:使用四元数代替欧拉角,或者使用Arcball旋转(虚拟轨迹球)。

6. 双指手势的初始状态

PinchGesture和RotationGesture的event.scaleevent.angle是相对于手势开始时的累计值。这意味着每次手势开始时,你需要记录当前的缩放/旋转状态,然后用累计值乘以初始状态来计算最终值。直接用累计值替换当前值会导致"跳变"。

7. 高亮效果的深度冲突

选中物体高亮时,如果使用描边效果(先渲染放大的背面作为描边,再渲染正面),可能会出现深度冲突(Z-fighting),导致描边闪烁。解决方案:描边pass使用独立的深度偏移(glPolygonOffset或手动在顶点着色器中沿法线方向外扩)。


五、HarmonyOS 6适配说明

API差异

API HarmonyOS 5.0 HarmonyOS 6.0 迁移建议
GestureGroup Exclusive/Parallel 新增Priority模式 使用Priority模式解决手势冲突
XComponent触摸 需手动获取触摸坐标 新增onTouch回调 使用内置触摸回调
3D选择 需手动实现射线拾取 @ohos.graphics3d内置hitTest 优先使用系统hitTest
手势识别 基础手势API 新增HoverGesture悬停手势 支持悬停预览效果
矩阵运算 需手动实现 @ohos.matrix4增强 使用系统矩阵库

行为变更

  • 手势优先级模式:HarmonyOS 6.0新增GestureMode.Priority模式,可以按优先级顺序识别手势,高优先级手势识别成功后低优先级手势自动取消
  • 3D场景内置交互:6.0的@ohos.graphics3d模块内置了射线拾取(hitTest方法),开发者无需手动实现Möller-Trumbore算法
  • 悬停手势:6.0新增HoverGesture,可以在手指悬停(不触碰)时触发预览效果,配合3D交互使用体验更好

适配代码

// HarmonyOS 6.0 使用系统3D交互能力
import { graphics3d } from '@ohos.graphics3d'

@Entry
@Component
struct ProductViewer6Page {
  private scene: graphics3d.Scene | null = null
  @State selectedPart: string = '未选中'

  async aboutToAppear() {
    this.scene = await graphics3d.createScene({ background: { color: '#0A0A1A' } })
    const model = await this.scene.loadModel('models/shoe.gltf', { async: true })

    // 启用内置3D交互
    this.scene.enableInteraction({
      rotate: true,        // 单指旋转
      scale: true,         // 双指缩放
      translate: false,    // 禁用平移
      hitTest: true,       // 启用射线拾取
      autoRotate: true,    // 自动旋转
      autoRotateSpeed: 0.5 // 旋转速度
    })

    // 监听选中事件
    this.scene.onObjectSelected((objectId: string) => {
      this.selectedPart = objectId || '未选中'
      // 高亮选中物体
      if (objectId) {
        this.scene?.highlightObject(objectId, {
          color: '#6C63FF',
          pulse: true,
          outline: true
        })
      }
    })
  }

  build() {
    Column() {
      // 使用系统3D渲染组件(内置手势支持)
      graphics3d.RenderView({
        scene: this.scene!,
        width: '100%',
        height: 400,
        // 手势直接由系统处理,无需手动绑定
      })

      Text(`选中: ${this.selectedPart}`)
        .fontSize(14)
        .fontColor('#FFFFFF')
        .margin({ top: 12 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0F0F23')
  }
}

六、总结

维度 评价
学习难度 ⭐⭐⭐⭐
使用频率 ⭐⭐⭐⭐⭐
重要程度 ⭐⭐⭐⭐⭐

3D交互的核心挑战是"2D到3D的映射"。屏幕是平的,手指只能给出2D坐标,但3D场景需要3D变换。射线拾取解决了"点哪里"的问题——把2D触摸点转换为3D射线,检测与物体的交点。手势映射解决了"怎么动"的问题——单指拖动映射为旋转,双指捏合映射为缩放,点击映射为选择。

实战中最大的坑是手势冲突和状态管理。单指拖动和点击的起始动作相同,双指手势的累计值需要正确处理初始状态,欧拉角旋转会遇到万向节锁。这些问题的解决方案分别是:使用优先级手势模式、记录手势开始时的状态、使用四元数代替欧拉角。

HarmonyOS 6.0的@ohos.graphics3d模块将3D交互内置化,开发者无需手写射线拾取和手势映射代码。但理解底层原理仍然重要——当系统模块无法满足定制需求时,你需要回到手动实现的道路上。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。