ios-ARKit 实现与3D模型交互(模型换肤、运动)

本章实现对模型(Demo中用了一个汽车模型)的交互操作,包括对汽车模型换肤、零件拆卸、轮胎运转、后视镜开合、以及车窗的升降等等。在与AR世界的交互之前对AR世界的构建,以及模型的展示在另一篇文章中(AR世界的构建:https://www.jianshu.com/p/f7c26b058348)这里就不再讲述。

构建出AR世界并且在AR世界中展示3D模型后就可以开始对模型进行各种操作及交互:
思考:如何实现对汽车模型或者其身上子模型部件进行操作?
一个复杂模型的制作原理是由多个材质球或者模型(SceneKit中的节点SCNNode)拼接而成的的。在SceneKit中我们可以通过检索模型的名称对其进行交互。

Demo中相关属性:列出以便文章阅读

@property (nonatomic, strong) UIButton *backButton;//返回按钮
@property (nonatomic, strong) ARSCNView *sceneView;//AR视图(AR场景填在在其上)
@property (nonatomic, strong) ARWorldTrackingConfiguration *configuration;//AR世界追踪
@property (nonatomic, strong) SCNScene *scene;//AR场景

@property (nonatomic, strong) ARPlaneAnchor *planAnchor;//平面锚点
@property (nonatomic, strong) SCNNode *planParanNode;//地面节点(模型放上面)
@property (nonatomic, assign) BOOL modelShowing;//是否已经显示模型(已经显示模型后不继续重新布置平面)
@property (nonatomic, assign) BOOL isSeachPlan;//是否已经找到平面

@property (nonatomic, strong) SCNNode *carModelNode;//汽车模型节点

@property (nonatomic, assign) BOOL tireSpared;//是否已经拆下轮胎

//颜色面板
@property (nonatomic, strong) HCColorPanelView *colorPanelView;
//菜单面板
@property (nonatomic, strong) UIButton *menuButton;
@property (nonatomic, strong) HCMenuPanelView *menuPanelView;

·碰撞检测 (点击手机屏幕,检测是否点击了模型)

给汽车模型起个名字:

self.carModelNode.name = @"modelCarNode";//很重要,根据这个那么做对比,是否点击了模型

点击屏幕后监听- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event方法,遍历点击事件,检测是否与模型进行了碰撞:

//点击检测(碰撞检测)
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    if (self.arType == ARWorldTrackingConfigurationType_planeDetection_CarDemo && self.modelShowing) {
        //已经放置了汽车模型,检测点击汽车事件
        UITouch *touch = [touches anyObject];
        CGPoint tapPoint  = [touch locationInView:self.sceneView];//该点就是手指的点击位置
        NSDictionary *hitTestOptions = [NSDictionary dictionaryWithObjectsAndKeys:@(true),SCNHitTestBoundingBoxOnlyKey, nil];
        NSArray<SCNHitTestResult *> * results= [self.sceneView hitTest:tapPoint options:hitTestOptions];
        for (SCNHitTestResult *res in results) {//遍历所有的返回结果中的node
            if ([self isNodeCarModelObject:res.node]) {
//                [[HCToast shareInstance] showToast:@"点击了汽车"];
                NSLog(@"点击了汽车模型...............");
                break;
            }
        }
    }
}

//上溯找寻指定的node(是否点击了汽车)
-(BOOL) isNodeCarModelObject:(SCNNode*)node {
    if ([@"modelCarNode" isEqualToString:node.name]) {
        return true;
    }
    if (node.parentNode != nil) {
        return [self isNodeCarModelObject:node.parentNode];
    }
    return false;
}

·给汽车模型换肤

给模型换肤原理就是修改汽车模型的材质贴图,那么久同样需要找到汽车车身的模型:


车身模型.png

上图中,我们可以打开汽车模型,选中车身,从左侧的模型列表中可以看到,车身的模型名称为“body_01”,那么我们就先去除“body_01”的SCNNode节点。

//修改汽车颜色
            SCNNode *bodyNode = [weakSelf.carModelNode childNodeWithName:@"body_01" recursively:YES];
            bodyNode.childNodes[0].geometry.firstMaterial.diffuse.contents = color;//这里的颜色值可以设置纯色或者设置图片。这样就达到了给汽车换皮肤的功能。

汽车换肤效果:


换肤.gif

·双指捏合缩放模型、拖拽旋转模型

首先捏合、拖拽就需要用到手势,给SCNView添加手势
缩放模型原理:当捏合开始时,记录开始捏合时模型的缩放比例,然后在手势变化的过程中计算当前手势scale除以手势开始时的scale, 以开始时模型的scale为基准相乘, 实现圆润的放大缩小效果。这个比例大小可以自己调整,以达到自己理想的缩放范围。

//给场景视图添加手势
- (void)addRecognizerToSceneView{
    UIPanGestureRecognizer *panGes = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panView:)];
    [self.sceneView addGestureRecognizer:panGes];
    UIPinchGestureRecognizer *pinchGes = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(pinchView:)];
    [self.sceneView addGestureRecognizer:pinchGes];
}

监听手势触发方法

// 处理拖拉手势 - 移动 旋转
- (void)panView:(UIPanGestureRecognizer *)panGestureRecognizer{
    if (self.modelShowing) {
        NSLog(@"拖拽.....................");
        UIView *view = panGestureRecognizer.view;
        CGPoint location = [panGestureRecognizer translationInView:self.sceneView];
        CGPoint velocityPoint = [panGestureRecognizer velocityInView:self.sceneView];
        switch (panGestureRecognizer.state) {
            case UIGestureRecognizerStateChanged:{
                
                //旋转模型
                float xx = velocityPoint.x/5000;
                float yy = velocityPoint.y/5000;
                self.carModelNode.eulerAngles = SCNVector3Make(0, self.carModelNode.eulerAngles.y + (fabs(xx) > fabs(yy) ? xx : -yy), 0);
                
                break;
            }
            case UIGestureRecognizerStateEnded:{
                return;
            }
                
            default:{
                break;
            }
        }
    }
    
}

// 处理缩放手势
CGFloat oldGesScale = Car_Model_Scale;
CGFloat oldModelScale = Car_Model_Scale;
- (void)pinchView:(UIPinchGestureRecognizer *)pinchGestureRecognizer{
    if (self.modelShowing){
//        NSLog(@"缩放.....................");
        if (pinchGestureRecognizer.state == UIGestureRecognizerStateBegan) {//手势开始
            oldGesScale = pinchGestureRecognizer.scale;//手势开始时,获取模型的比例
            oldModelScale = self.carModelNode.scale.x;//手势开始时,获取模型的scale
        }
        
        if (pinchGestureRecognizer.state == UIGestureRecognizerStateChanged) {
            //计算, 当前手势scale除以手势开始时的scale, 以开始时模型的scale为基准相乘, 实现圆润的放大缩小效果
            CGFloat currentGesScale = pinchGestureRecognizer.scale;
            CGFloat scale = oldModelScale *  (float)(currentGesScale / oldGesScale);
            scale = scale < 0.005 ? 0.005 : scale;
            scale = scale > 0.05 ? 0.05 : scale ;
            self.carModelNode.scale = SCNVector3Make(scale, scale, scale);
        }
        
    }
}

·汽车零件拆卸 - 拆卸轮胎

零件拆卸原理:同样需要从模型中读取轮胎的模型,同样可以再模型中查看轮胎的模型名称。轮胎模型又是由许多小零件组成,一般模型师会将其放在一个组内,组成一个轮胎模型:如下图:轮胎模型组为"Group002"


轮胎模型.png

拿到轮胎模型后,进行拆卸动作:将模型进行位移和旋转,造成轮胎与车身存在位置与角度的差别,从而实现轮胎(或其他零件)拆卸的功能。
同理,零件复原可以将拆卸下的零件经过位移和旋转进行复位。
零件拆卸和复位方法:

/**
拆汽车零件 

@param sparePartsName 汽车零件模型名称
@param spareDistance 拆卸偏离距离 (为0时,使用默认距离)
@param beFlip 是否翻转模型
*/
- (void)removePartsCar:(NSString *)sparePartsName spareDistance:(CGFloat)spareDistance beFlip:(BOOL)beFlip{
   for (SCNNode *partsNode in self.carModelNode.childNodes) {
       if ([partsNode.name isEqualToString:sparePartsName]) {
           //找到对应的零件模型
           [UIView animateWithDuration:1.0 animations:^{
               //零件往外移动
               partsNode.position = SCNVector3Make(partsNode.position.x + spareDistance ,partsNode.position.y,partsNode.position.z);
           } completion:^(BOOL finished) {
               if (beFlip) {
                   //零件翻转
                   partsNode.eulerAngles = SCNVector3Make(0, 0, M_PI/2);
               }
           }];
           
       }
   }
}

/**
安装拆下的零件

@param sparePartsName 汽车零件模型名称
@param spareDistance 拆卸偏离距离 (为0时,使用默认距离)
@param beFlip 是否翻转模型
*/
- (void)recoveryPartsCar:(NSString *)sparePartsName spareDistance:(CGFloat)spareDistance beFlip:(BOOL)beFlip{
   for (SCNNode *partsNode in self.carModelNode.childNodes) {
       if ([partsNode.name isEqualToString:sparePartsName]) {
           //找到对应的零件模型 Group002:左前轮
           [UIView animateWithDuration:1.0 animations:^{
               if (beFlip) {
                   //零件翻转
                   partsNode.eulerAngles = SCNVector3Make(0, 0, 0);
               }
           } completion:^(BOOL finished) {
               //零件回到原来位置
               partsNode.position = SCNVector3Make(partsNode.position.x - spareDistance ,partsNode.position.y,partsNode.position.z);
           }];
           
       }
   }
}

拆卸零件:


拆卸零件.gif

·轮胎运转

拿到四个轮胎模型后,对齐进行旋转。正常逻辑,轮胎旋转是绕X轴进行无限循环转动。使用贝塞尔动画(CABasicAnimation)进行旋转:

//开始轮胎转动
- (void)startTireTurnningModel:(NSString *)modelName duration:(NSTimeInterval)duration{
    for (SCNNode *partsNode in self.carModelNode.childNodes) {
        if ([partsNode.name isEqualToString:modelName]) {
            //创建自转动画
            CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"rotation"];
            animation.duration = duration;
            animation.toValue = [NSValue valueWithSCNVector4:SCNVector4Make(0,1,0, M_PI *2)];
            animation.repeatCount = FLT_MAX;
            [partsNode addAnimation:animation forKey:@"tire rotation"];
            [partsNode runAction:[SCNAction repeatActionForever:[SCNAction rotateByX:2 y:0 z:0 duration:duration]]];//轮胎自转 绕X轴自转
        }
    }
}

想要停止轮胎转动,移除其动画:

//停止轮胎转动
- (void)stopTireTurnningModel:(NSString *)modelName{
    for (SCNNode *partsNode in self.carModelNode.childNodes) {
        if ([partsNode.name isEqualToString:modelName]) {
            //需要同时remove Animation和Actions,只移除其中一个无效
            [partsNode removeAnimationForKey:@"tire rotation"];
            [partsNode removeAllActions];
        }
    }
}

轮胎运转效果:


hou

·后视镜折叠与车窗升降效果的实现:

后视镜开合的原理与轮胎转动的原理是一样的,位移的差别就是围绕的旋转轴(后视镜围绕Y轴旋转,右手坐标系)、旋转角度、旋转次数不一样:

//合上后视镜
- (void)closeRearviewMirrorModel:(NSString *)modelName angle:(CGFloat)angle Duration:(NSTimeInterval)duration{
    for (SCNNode *partsNode in self.carModelNode.childNodes) {
        if ([partsNode.name isEqualToString:modelName]) {
            
            //创建自转动画
            CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"rotation"];//执行的是旋转
            animation.duration = duration;
//            animation.toValue = [NSValue valueWithSCNVector4:SCNVector4Make(0,0,0, 0)];//旋转角度
            animation.repeatCount = 1;
            [partsNode addAnimation:animation forKey:@"rearviewMirror rotation"];
            [partsNode runAction:[SCNAction repeatAction:[SCNAction rotateByX:0 y:angle z:0 duration:duration] count:1]];//后视镜绕Y轴旋转 angle角度
        }
    }
}

//打开后视镜
- (void)openRearviewMirrorModel:(NSString *)modelName angle:(CGFloat)angle Duration:(NSTimeInterval)duration{
    for (SCNNode *partsNode in self.carModelNode.childNodes) {
        if ([partsNode.name isEqualToString:modelName]) {
            //创建自转动画
            CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"rotation"];
            animation.duration = duration;
//            animation.toValue = [NSValue valueWithSCNVector4:SCNVector4Make(0,1,0, 0)];
            animation.repeatCount = 1;
            [partsNode addAnimation:animation forKey:@"rearviewMirror2 rotation"];
            [partsNode runAction:[SCNAction repeatAction:[SCNAction rotateByX:0 y:angle z:0 duration:duration] count:1]];//后视镜绕Y轴旋转
        }
    }
}

后视镜折叠:


后视镜.gif

而车窗升降与后视镜旋转存在不一样的地方是,车窗的升降使用的是CABasicAnimation的平移而不是旋转。

CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position"];//执行平移动画

核心代码是从哪里(fromValue)移动到哪里(toValue):

/**
 降下车窗

 @param modelName 模型对象名称
 @param duration 执行周期
 */
- (void)downWindowsWithModelName:(NSString *)modelName offsetY:(CGFloat)offsetY duration:(NSTimeInterval)duration{
    for (SCNNode *windowNode in self.carModelNode.childNodes) {
        if ([windowNode.name isEqualToString:modelName]) {
            //创建自转动画
            CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position"];//执行平移动画
            animation.duration = duration;
            animation.toValue = [NSValue valueWithSCNVector3:SCNVector3Make(windowNode.position.x, windowNode.position.y-offsetY, windowNode.position.z)];
            animation.removedOnCompletion = NO;
            animation.fillMode = @"forwards";
            [windowNode addAnimation:animation forKey:@"window position"];
        }
    }
}

//升起车窗
- (void)upWindowsWithModelName:(NSString *)modelName offsetY:(CGFloat)offsetY duration:(NSTimeInterval)duration{
    for (SCNNode *windowNode in self.carModelNode.childNodes) {
        if ([windowNode.name isEqualToString:modelName]) {
            //创建自转动画
            CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position"];//执行平移动画
            animation.duration = duration;
            animation.fromValue = [NSValue valueWithSCNVector3:SCNVector3Make(windowNode.position.x, windowNode.position.y-offsetY, windowNode.position.z)];
            animation.toValue = [NSValue valueWithSCNVector3:SCNVector3Make(windowNode.position.x, windowNode.position.y, windowNode.position.z)];
            animation.removedOnCompletion = NO;
            animation.fillMode = @"forwards";
            [windowNode addAnimation:animation forKey:@"window position"];
        }
    }
}

升降车窗效果:


车窗升降.gif

本文Demo Git下载地址:https://github.com/heqican/ARKitCarModel

推荐阅读更多精彩内容