HarmonyOS APP开发: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交互需要管理多种状态,避免手势冲突:
IDLE → DRAG_ROTATE(单指拖动)
IDLE → PINCH_SCALE(双指捏合)
IDLE → TAP_SELECT(点击选择)
DRAG_ROTATE → IDLE(手指抬起)
PINCH_SCALE → IDLE(手指抬起)
三、代码实战
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.offsetX和event.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.scale和event.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交互内置化,开发者无需手写射线拾取和手势映射代码。但理解底层原理仍然重要——当系统模块无法满足定制需求时,你需要回到手动实现的道路上。
- 点赞
- 收藏
- 关注作者
评论(0)