使用SceneKit编写VR全景播放器
最近用SceneKit做了全景看房的功能,現總結下如何實現的。
先看下最終的效果:
gif1.gif
VR圖片全景播放器有以下功能:
- 360度
- 手勢滑動,縮放
- 陀螺儀
- 分屏(VR眼鏡)
- 熱點hotpot
- 頭控/eyepick
手勢滑動,縮放,陀螺儀功能都是調節球面圖片顯示的位置;
熱點和頭控功能本質是一樣的,都是在原有模型上增加3維的視圖。它們用途不一樣,頭控功能(全景圖片一般就是eyepick功能)一般是戴VR眼鏡后,通過模型的位置觸發控制事件。
展示全景圖的原理很簡單:將圖片渲染至球體模型內表面上,手機處于球體中心(圖中紅色區域),當旋轉手機的時候,
球體向相反的方向旋轉,這樣我們就可以看到球體上的畫面了。
球
怎么將圖片繪制于球體上呢?
這需要使用openGL這個框架,openGL渲染球體圖片步驟大致如下:
頂點數據
全景播放器第三方庫
-
MD360Player4iOS:支持全景圖片/視頻,有分屏/陀螺儀/手勢移動功能,但沒有熱點及頭控功能;
-
Panorama:只支持全景圖片,比較輕量。也只有分屏/陀螺儀/手勢功能;
-
PanoramaGL:只支持全景圖片,具有陀螺儀/手勢/熱點功能,但這個庫比較久遠仍是MRC,沒人維護;
-
得圖SDK:支持全景圖片/視頻,也只有分屏/陀螺儀/手勢移動功能
現在主流的和全景圖片有關的三方庫,基本上都沒有熱點及頭控功能;之前有試過在MD360Player4iOS基礎上增加這兩個功能,但因為自己openGL零基礎后來還是暫時放棄了。
后來發現系統SceneKit框架也可以實現以上所有功能,使用起來也非常簡單。接下來我們來了解下SceneKit,看如何實現全景播放功能。
SceneKit
(全景視頻播放器需使用SpriteKit,這里主要先介紹圖片播放器,之后再講視頻播放器)
SceneKit是什么?
SceneKit is a high-level 3D graphics framework that helps you create 3D animated scenes and effects in your apps. It incorporates a physics engine, a particle generator, and easy ways to script the actions of 3D objects so you can describe your scene in terms of its content — geometry, materials, lights, and cameras — then animate it by describing changes to those objects.
SceneKit是一個高級的3D圖形框架,它幫助您在應用程序中創建3D動畫場景和效果。它包含了一個物理引擎,一個粒子發生器,以及簡單的方法來編寫3D對象的動作腳本,這樣你就可以用它的內容來描述你的場景——幾何,材料,燈光和攝像機——然后通過描述這些對象的變化來動畫它。
SceneKit是處理3D圖形的,在介紹怎么使用SceneKit 時。我們先來看下與3D有關的知識:坐標系與旋轉表達式。
- SceneKit的3D坐標系為右手坐標系:
這個坐標系沒有單位,而是根據屏幕的寬度和高度進行相對運算,屏幕上邊為1 下邊為-1 左邊為 -1 右邊為 1 。
請牢記這個坐標系,接下來有關圖形處理都繞不開它。
坐標系
- 旋轉表達式
旋轉表達式主要有四種: - 軸角 2. 歐拉角 3. 四元素 4. 旋轉矩陣
這篇博客大概介紹了這四種表達式。旋轉表達式主要處理模型在空間位置的旋轉,全景圖片播放時需要用到。
SceneKit比較強大,類比較多,接下來只主要介紹與實現全景有關的幾個類:
- SCNView
SCNView主要負責顯示3D模型對象的視圖,能夠添加到UIView類型的視圖上。 - SCNScene
場景:由幾何模型,燈光,照相機及其他屬性組成的環境。場景能添加各種節點,
他包含了一個rootNode(根節點)屬性,可以添加各種node。 - SCNNOde
節點:一個抽象的概念,是個看不見摸不到的東西,沒有幾何形狀,但是有位置,以及自身坐標系。在場景中添加節點后,就可以在這個節點上放我們的元素了,比如幾何模型,燈光,攝像機等。節點上可以添加子節點的,每個節點都有自身坐標系。
它的屬性包含:camera geometry position rotation eulerAngles pivot orientation等,其中rotation eulerAngles pivot orientation就是各種旋轉表達式,可以處理模型在空間的角度。 - SCNGeometry
幾何模型:全景圖片就是渲染在模型上的然后顯示在屏幕上。系統自帶的模型有很多種:SCNPlane SCNBox SCNSphere SCNCylinder SCNText。我們也可以通過SCNShape自定義各種奇形怪狀的模型。 - SCNCamera
相機(觀察者):這個類似我們現實中的相機,它也有焦距、視角等。圖形渲染到模型后,要添加相機我們才能看見。 - 視角:xFov yFov(默認60度),視角越大,屏幕上顯示的體積越小;
- 焦距:focusDistance(默認2.5),焦距越大,視角越小;
camera
- SCNAction
動畫:可以為節點添加各種動畫,包括:移動,旋轉,縮放,自定義…
怎么設置才能將圖片渲染至模型上呢?這里需要先理解SCNGeometry的相關幾個屬性:
- materials(SCNMaterial類):材質,要渲染的圖片就是添加到材質上。一個模型可以添加多個材質,默認有一個材質,可以通過firstMaterial屬性獲取。
- cullMode(SCNMaterial屬性):渲染時剔除的表面,SCNCullModeBack內表面,SCNCullModeFront外表面。
- diffuse(SCNMaterial屬性):
The diffuse property specifies the amount of light diffusely reflected from the surface. The diffuse light is reflected equally in all directions and is therefore independent of the point of view.
漫反射屬性指定從表面漫反射的光量。漫射光在各個方向上反射均勻,因此與視點無關。 - contents(diffuse.contents):渲染的內容,可以是顏色,圖片,圖層,路徑,紋理等。
全景圖片渲染設置:geometry.firstMaterial.diffuse.contents = image;就可以了。
理解了一些基本知識后,開始編寫代碼:
顯示圖片
// 初始化scene_scnView = [[SCNView alloc] init];_scnView.scene = [SCNScene scene];[self.view addSubview:_scnView];// 繪制球體SCNSphere *sphere = [SCNSphere sphereWithRadius:_config.shpereRadius];// 前面提過坐標系是根據屏幕相對運算的,具體值可以根據顯示效果調節,這里球體radius設置為10,sphere.firstMaterial.cullMode = SCNCullModeFront; // 剔除球體外表面sphere.firstMaterial.doubleSided = NO; // 只渲染一個表面// 相機是處于球體內部的,_sphereNode = [SCNNode node]; // 節點_sphereNode.geometry = sphere;_sphereNode.position = SCNVector3Make(0, 0, 0); // 位置(屏幕中心)// 渲染圖片sphere.firstMaterial.diffuse.contents = _config.contents;[_scnView.scene.rootNode addChildNode:_sphereNode]; // 添加至場景根節點到這里,一個內表面顯示圖片的球體創建并添加成功,但是現在view上面并不顯示,還需要添加相機節點:
// 相機_camera = [SCNCamera camera];_camera.automaticallyAdjustsZRange = YES; // 自動添加可視距離_camera.xFov = _config.cameraFocalX; // 相機視角_camera.yFov = _config.cameraFocalY;_camera.focalBlurRadius = 0; // 模糊_cameraNode = [SCNNode node];_cameraNode.camera = _camera;[_scnView.scene.rootNode addChildNode:_cameraNode];然后運行代碼,手機屏幕上就能看到圖片了。
demo
如果仔細對比原始的平鋪圖片會發現,現在顯示的圖片是反過來的,是鏡像的;這是因為圖片是貼在球體上,而我們的相機是從球體中心往外觀察的,類似于現實世界中我們在房間里看貼在窗戶玻璃外的窗花一樣
我們如何讓它正常顯示呢?前面分析過圖片渲染的原理,關鍵的一點就是紋理,那么翻轉紋理坐標就能解決這個問題了:
這里使用了矩陣操作,先把坐標沿y軸翻轉實現鏡像,翻轉后坐標偏移了所以接著需要平移回來。
還有一種方式,翻轉后不平移,而是指定超出紋理坐標范圍的紋理映射行為SCNWrapMode:mode有以下四種
wrap
指定repeat即可
sphere.firstMaterial.diffuse.contentsTransform = SCNMatrix4MakeScale(-1, 1, 1);sphere.firstMaterial.diffuse.wrapS = SCNWrapModeRepeat;sphere.firstMaterial.diffuse.wrapT = SCNWrapModeRepeat;但這時僅僅顯示了全景圖的一部分,并不支持360度查看及陀螺儀查看等功能。我們可以添加手勢及陀螺儀來控制全景圖的360度滑動:
手勢滑動,縮放功能
在scnView父視圖上添加兩個手勢:pinchGesture,panGesture。根據手勢操作,調節相機的參數實現相應功能:
- (void)addGesture {self.pinchGesture = [[UIPinchGestureRecognizer alloc]initWithTarget:self action:@selector(pinchGesture:)];self.panGesture = [[UIPanGestureRecognizer alloc]initWithTarget:self action:@selector(panGesture:)];[self addGestureRecognizer:_pinchGesture];[self addGestureRecognizer:_panGesture];_pinchGesture.enabled = _config.pinchEnabled;_panGesture.enabled = _config.panEnabled; }- (void)pinchGesture:(UIPinchGestureRecognizer *)gesture {if (gesture.state != UIGestureRecognizerStateEnded && gesture.state != UIGestureRecognizerStateFailed) {if (gesture.scale != NAN && gesture.scale != 0.0) {float scale = gesture.scale - 1;if (scale < 0) {scale *= (_config.scaleMax - _config.scaleMin);}_currentScale = scale + _prevScale;_currentScale = [self validateScale:_currentScale]; // 控制縮放的最小最大比例CGFloat valScale = [self validateScale:_currentScale];double xFov = _config.cameraFocalX * (1 - (valScale - 1));double yFov = _config.cameraFocalY * (1 - (valScale - 1));// 調節相機視角,前面分析了視角越大看到的體積越小,所以這里要反過來。即手勢放大時,視角要調小這樣看到的圖像才是放大的效果;_camera.xFov = xFov;_camera.yFov = yFov;}} else if(gesture.state == UIGestureRecognizerStateEnded){_prevScale = _currentScale;} }- (void)panGesture:(UIPanGestureRecognizer *)gesture {// 控制圖片滑動原理:手勢滑動,效果是手機屏幕上的圖片要跟著滑動,// 因為我們的圖片是渲染至球體上的,所以可以控制球體轉動來實現滑動效果。// 一般的,我們都是控制相機(觀察者)。因為相機處于球體內部,相機需要往相反的方向轉動。if (gesture.state == UIGestureRecognizerStateBegan){CGPoint currentPoint = [gesture locationInView:gesture.view];self.lastPointX = currentPoint.x;self.lastPointY = currentPoint.y;}else{CGPoint currentPoint = [gesture locationInView:gesture.view];float distX = currentPoint.x - self.lastPointX;float distY = currentPoint.y - self.lastPointY;self.lastPointX = currentPoint.x;self.lastPointY = currentPoint.y;// 手勢滑動角度的微調distX *= - 0.005 * 0.5;distY *= - 0.005 * 0.5;SCNMatrix4 modelMatrix = SCNMatrix4Identity;if (fabs(distX) > fabs(distY)) {self.fingerRotationY += distX;}else {self.fingerRotationX += distY;}// 因為是右手坐標系,所以相機水平轉動時是繞Y軸轉動,垂直方向轉動時需繞X軸轉動。Z軸保持不變。這里旋轉表達式用的是旋轉矩陣modelMatrix = SCNMatrix4Rotate(modelMatrix, self.fingerRotationY, 0, 1, 0);modelMatrix = SCNMatrix4Rotate(modelMatrix, self.fingerRotationX,1, 0, 0);_cameraNode.pivot = modelMatrix;} }- (float)validateScale:(float)scale{if (scale < _config.scaleMin) {scale = _config.scaleMin;}else if (scale > _config.scaleMax) {scale = _config.scaleMax;}return scale; }陀螺儀功能
陀螺儀功能是讓圖片跟著手機的方位轉動,原理和手勢滑動一樣:
- (void)addMotionFunction {_motionManager = [[CMMotionManager alloc]init];_motionManager.deviceMotionUpdateInterval = 1.0 / 30.0;_motionManager.gyroUpdateInterval = 1.0f / 30;_motionManager.showsDeviceMovementDisplay = YES;if (_motionManager.isDeviceMotionAvailable) {[_motionManager startDeviceMotionUpdatesToQueue:[NSOperationQueue mainQueue] withHandler:^(CMDeviceMotion * _Nullable motion, NSError * _Nullable error) {if (!self.config.motionEnabled) {return;}CMAttitude *attitude = motion.attitude;if (attitude == nil) {return;}// self.cameraNode.eulerAngles = SCNVector3Make(attitude.pitch - M_PI / 2 , attitude.roll, attitude.yaw);// 這里旋轉表達式用的是四元素(陀螺儀返回的attitude.quaternion就是四元素)self.cameraNode.orientation = [self orientationFromCMQuaternion:attitude.quaternion];}];} }- (SCNQuaternion)orientationFromCMQuaternion:(CMQuaternion)quaternion {GLKQuaternion gq1 = GLKQuaternionMakeWithAngleAndAxis(GLKMathDegreesToRadians(- 90), 1, 0, 0);// 這里x軸要同時旋轉90度,這是因為手機陀螺儀的坐標系不一致:手機正放于桌面上的坐標為(0,0,0);而scnView坐標系是手機正立的時候為(0,0,0);GLKQuaternion gq2 = GLKQuaternionMake(quaternion.x, quaternion.y, quaternion.z, quaternion.w);GLKQuaternion qp = GLKQuaternionMultiply(gq1, gq2);return SCNVector4Make(qp.x, qp.y, qp.z, qp.w); }添加遮罩
大部分全景圖片都是由全景相機拍攝出來的,全景相機是360度的,在拍攝時相機底部的支架也會拍攝進去:
支架
為了美觀,不影響整體效果 ,我們需要用一張圖片蓋住。怎么在球面圖形上面加張圖片呢?其實我們只要在創建一個渲染圖片的平面模型,找準位置添加到場景rootNode上就可以了:
_overlayNode = [SCNNode node];_overlayNode.geometry= [SCNPlane planeWithWidth:1 height:1];_overlayNode.geometry.firstMaterial.diffuse.contents = overlayIcon; // 圖片_overlayNode.position = SCNVector3Make(0, - 4, 0); // 支架位于相機正下方,也就是坐標系Y軸負方向_overlayNode.rotation = SCNVector4Make(1, 0, 0, - M_PI / 2); // 旋轉 否則看不到// 這里旋轉90度 還是坐標的原因:默認情況下添加的SCNPlane模型是平鋪在XY平面,而我們添加的遮罩X,Z都是0,所以需要旋轉至XZ平面才能看到遮罩_overlayNode.geometry.firstMaterial.cullMode = SCNCullModeBack;[_scnView.scene.rootNode addChildNode:_overlayNode];遮罩
頭控功能(eyepick)
其原理和上面的添加遮罩是一樣的,都是在場景中添加節點。不過這些節點需要觸發事件,實現相關的控制功能。這里的控制功能基本都是控制切換上一張圖片,下一張圖片,實現頭戴設備后也能實現查看圖集的需求。
// 添加頭控節點 _potNode = [SCNNode node]; // 選擇pick節點_potNode.geometry= [SCNPlane planeWithWidth:0.3 height:0.3];_potNode.geometry.firstMaterial.diffuse.contents = potIcon;_potNode.position = SCNVector3Make(0, 0, - 9); _potNode.geometry.firstMaterial.cullMode = SCNCullModeBack;[_cameraNode addChildNode:_potNode]; // 加在_camera上,camera轉動時保持不變_preNode = [SCNNode node]; // 上一張圖片function節點_preNode.geometry= [SCNPlane planeWithWidth:0.3 height:0.3];_preNode.geometry.firstMaterial.diffuse.contents = preIcon;_preNode.position = SCNVector3Make(- 1.5, 0.5, - 9);_preNode.geometry.firstMaterial.cullMode = SCNCullModeBack;[_sphereNode addChildNode:_preNode];_nextNode = [SCNNode node]; // 下一張圖片function節點_nextNode.geometry= [SCNPlane planeWithWidth:0.3 height:0.3];_nextNode.geometry.firstMaterial.diffuse.contents = nextIcon;_nextNode.position = SCNVector3Make(1.5, 0.5, - 9);_nextNode.geometry.firstMaterial.cullMode = SCNCullModeBack;[_sphereNode addChildNode:_nextNode];節點添加完后,并正常顯示了,接下來就要加上觸發事件,觸發的時機就是當function節點和pick節點重合的時候。只判斷重合還不夠,因為在瀏覽圖片時,相機轉動時偶發情況下function節點和pick節點碰巧重合。因此在重合的基礎上,還需加上延時動畫,當重合的時間達到動畫的時間后才觸發事件。
// 添加頭控動畫 - (void)addEyepickerAnimation {_animationNode = [SCNNode node];_animationNode.geometry = [SCNPlane planeWithWidth:0.3 height:0.3];_animationNode.hidden = YES;[_potNode addChildNode:_animationNode];__weak typeof(self) weakSelf = self;_animationAction = [SCNAction customActionWithDuration:3.f actionBlock:^(SCNNode * _Nonnull node, CGFloat elapsedTime) {int time = (int) (elapsedTime * (images.count - 1) / 3.0);node.geometry.firstMaterial.diffuse.contents = images[time];if (time == images.count - 1 && (weakSelf.isPreAnimating || weakSelf.isNextAnimating)) { // 動畫結束FWPanoramaHotpotType type = [weakSelf.animationKey isEqualToString:@"pre"] ? FWPanoramaHotpotTypePrev : FWPanoramaHotpotTypeNext;if (type == FWPanoramaHotpotTypePrev) {weakSelf.preAnimationEnd = YES;[weakSelf removePreAnimation];}else {weakSelf.nextAnimationEnd = YES;[weakSelf removeNextAnimation];}if ([weakSelf.delegate respondsToSelector:@selector(renderView:didPickHotpot:)]) {[weakSelf.delegate renderView:weakSelf didPickHotpot:type];}}}]; }// scnView的代理方法,圖片渲染都會走這里 - (void)renderer:(id <SCNSceneRenderer>)renderer updateAtTime:(NSTimeInterval)time {SCNVector3 prePosition = [_preNode convertPosition:_preNode.position toNode:_cameraNode]; // 計算相對坐標SCNVector3 nextPosition = [_nextNode convertPosition:_nextNode.position toNode:_cameraNode]; // NSLog(@"camera x;%f,y:%f,z:%f",prePosition.x,prePosition.y,prePosition.z);BOOL preOverlap = prePosition.x > - 0.3 / 2 && prePosition.x < 0.3 / 2 && prePosition.y > - 0.3 / 2 && prePosition.y < 0.3 / 2;if (!_preAnimationEnd && preOverlap) {// 兩個node基本重合if (!_isPreAnimating) {[self runPreAnimation];}}else if (!_isNextAnimating && !preOverlap) {_preAnimationEnd = NO;[self removePreAnimation];}BOOL nextOverlap = nextPosition.x > - 0.3 / 2 && nextPosition.x < 0.3 / 2 && nextPosition.y > - 0.3 / 2 && nextPosition.y < 0.3 / 2;if (!_nextAnimationEnd && nextOverlap) {// 兩個node基本重合if (!_isNextAnimating) {[self runNextAnimation];}}else if (!_isPreAnimating && !nextOverlap) {_nextAnimationEnd = NO;[self removeNextAnimation];} }節點點擊事件
上面兩個eyepick節點的事件,是由頭控觸發的;那如果我們要做到通過手動點擊節點來觸發事件,該怎么做呢?
(以上代碼片段由樓下junior_a提供)
分屏功能
實現分屏,就是將1個scnView分成兩個,這兩個scnView的顯示和操作都是一樣的。要實現這種效果,可以添加兩個subview并將scnView的contents賦值給兩個subview。
@property (nonatomic, strong) SCNView *leftView; @property (nonatomic, strong) SCNView *rightView;[_leftView mas_makeConstraints:^(MASConstraintMaker *make) {make.left.right.top.mas_equalTo(0);make.height.mas_equalTo(self.bounds.size.height / 2);}];[_rightView mas_makeConstraints:^(MASConstraintMaker *make) {make.left.right.bottom.mas_equalTo(0);make.height.mas_equalTo(self.bounds.size.height / 2);}]; _leftView.layer.contents = self.scnView.layer.contents; _rightView.layer.contents = self.scnView.layer.contents; [self.view addSubview:_leftView]; [self.view addSubview:_rightView];視頻播放器
視頻播放器,原理和圖片播放器是一樣的:改動上面的一小段代碼,就能實現和圖片同樣功能的視頻播放器;
改動的地方就是將渲染在球體模型上的圖片,換成skView包裝的視頻播放器AVPlayer:
另外,和普通的視頻播放器一樣,我們可以通過_player對象控制視頻的播放(播放/暫停/快進等)
至此,全景播放器的所有功能都實現了。所有代碼也就400行,是不是很簡單呢
覺得有用的點個贊哈
作者:_小沫
鏈接:https://www.jianshu.com/p/043e0e2abdb7
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
總結
以上是生活随笔為你收集整理的使用SceneKit编写VR全景播放器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 网络仿真软件性能比较
- 下一篇: 海康威视笔试