SceneKit的厄运。 Yandex在iOS中使用3D图形的经验

-我还太年轻而死。


SceneKit是iOS中的高级3D图形框架,可帮助创建动画场景和效果。 它包括一个物理引擎,一个粒子生成器和一组用于3D对象的简单操作,使您可以根据内容(几何,材质,照明,相机)描述场景,并通过对这些对象的更改进行描述来对其进行动画处理。



今天,我们将以稍微有些呆板的表情看一下SceneKit,但是首先,让我们看一下基础知识,看看3D场景是什么样的,以及创建它需要做些什么。


其中包含几何的的三个附件的最简单的场景。
其中包含几何的三个节点的最简单场景


首先,您需要创建场景的基本结构,该结构由场景的节点或节点组成。 每个节点可以同时包含几何和其他节点。 在外部编辑器中创建的几何形状可以是简单的(例如球,立方体或金字塔),也可以是更复杂的形状。


覆盖材料
覆盖材料


然后,对于这种几何形状,您需要指定将确定对象基本表示形式的材料 。 每种材料本身都会设置自己的照明模型,并根据其使用不同的属性集。 每个这样的属性通常都是颜色或纹理,但是除了这些常用的选项之外,还有使用CALayerAVPlayerSKScene的选项


添加光源
添加光源


之后,有必要添加确定对象在场景的一个或另一部分中可见程度的光源 。 与几何类似,它们必须位于节点内部。 SceneKit支持许多不同类型的照明以及几种类型的阴影


开箱即用的博克效果
开箱即用的博克效果


然后,您需要创建一个摄像头 (并将其放置在单独的节点中)并为其设置基本参数。 它们有很多,但是在它们的帮助下,您可以创建很酷的效果。 开箱即用,支持散景(或模糊),具有自适应,发光,SSAO和色相/饱和度修改的HDR。


SceneKit的简单动画
SceneKit中的简单动画


最后,SceneKit包括一组用于3D对象的简单操作,使您可以设置场景随时间的变化。 SceneKit还支持JavaScript中描述的操作 ,但这是另一篇文章的主题。


粒子生成器与物理引擎的相互作用可能导致龙卷风!
粒子生成器与物理引擎的相互作用可能导致龙卷风!


除图形外,SceneKit的主要功能还包括粒子生成器和高级物理引擎,该引擎可让您为普通对象和生成器中的粒子设置真实的物理属性。


关于所有这些芯片的大量详细教程已经​​编写。 但是在开发过程中,我们几乎没有利用这些机会...


嘿,不太粗糙


一旦我为3D游戏编写了比真实日光更好的照明模型,并在Nvidia 8800上提供了可接受的FPS,但我决定不释放引擎,因为上帝对我很好,我也不想在这件事上表现出无能。
-约翰·卡马克

我们将以一个非常简单的任务开始详细的研究,几乎所有认真对待SceneKit的人都会遇到这个问题:如何加载具有复杂几何图形,连接的材质,照明甚至动画的模型?


有几种方法,它们各有利弊:


  1. SCNScene(名为:)-从捆绑中获取场景,


  2. SCNScene(url:options :)-通过URL加载场景,


  3. SCNScene(mdlAsset :)-转换不同格式的场景,


  4. SCNReferenceNode(url :)-延迟加载场景。



从捆绑中获取场景


您可以使用标准方法 :将我们的模型以dae或scn格式放入scnassets捆绑包中,然后通过类比于UIImage(名为:)从那里加载。


但是,如果您希望自己控制模型的更新而又不需要每次需要更改几个纹理时都在App Store中发布更新,该怎么办? 或者假设您需要支持用户创建的地图和模型。 或者-您根本不想增加应用程序的大小,因为其中的3D图形不是主要功能。


通过URL加载场景


您可以 scn文件的URL使用场景构造函数 。 此方法不仅支持从文件系统下载,还支持从网络下载,但在后一种情况下,您可以忽略压缩。 另外,您需要提前将模型转换为scn格式。 您当然可以使用dae,但是它附带了一组限制。 例如,缺少基于物理的渲染。


这种方法的主要优点是它允许您灵活地配置导入设置 。 例如,您可以修改动画的生命周期,并使它们不断重复。 您可以显式指定加载诸如纹理之类的外部资源的源,可以转换场景的方向和比例,为几何创建缺失的法线,将场景的整个几何合并到一个大节点中或丢弃所有不符合格式标准的场景元素。


转换不同格式的场景


第三种选择是将构造函数与MDLAsset一起使用。 也就是说,首先我们在ModelIO框架中创建一个MDLAsset ,然后将其传递给场景的构造函数。


此选项很好,因为它允许您下载许多不同的格式。 正式地,MDLAsset可以加载obj,ply,stl和usd格式,但是在列出了所有可能的格式列表之后,至少以某种方式与计算机图形相关,我发现了另外四个格式:abc,bsp,vox和md3,但它们可能不受完全支持。在所有系统中,对于它们,您需要检查导入的正确性。


还必须考虑到此方法的转换开销很大,请务必谨慎使用。


这些方法有一个常见的陷阱:它们返回SCNScene,而不是SCNNode。 向现有场景添加内容的唯一方法是复制所有子节点,并且-您可以轻松地跳过此步骤-来自根节点的动画(例如,与dae一起使用时,它们可以出现在此处)。 另外,您需要考虑到场景中只能有一个纹理环境(如果您不使用自定义着色器进行反射)。


延迟加载场景


第四个选项是使用SCNReferenceNode 。 它不返回场景,而是返回一个节点,该节点本身可以​​延迟(或应要求)将场景的整个层次结构加载到自身中。 因此,该方法与第一种方法相似,但它将所有复制问题隐藏在自身内部。


他只有一件事:场景的全局参数丢失。


事实证明,这是下载模型的最简单,最快的方法,但是如果需要文件调整,则第一种方法会更好。


结果,我们选择了第一个选项,因为这对于我们以scn格式工作和设计人员来说最方便-从dae格式转换为它。 另外,我们需要在启动时调整文件动画。


根本不是过早的优化


经过很长一段时间的修补,我可以为您提供一些建议。


最重要的技巧是提前将文件转换为scn。 然后,通过在Xcode的内置场景编辑器中打开文件,可以查看对象在SceneKit中的外观。


另外,实际上,scn文件只是场景的二进制表示,因此从其中进行加载将花费最少的时间。 对于同一dae,必须首先解析xml,然后转换所有网格,动画和材质。 而且,动画和素材的转换是潜在的问题根源。 我们回想起dae缺乏PBR支持:事实证明,如果要使用它,则必须在转换后更改所有材质的类型,并手动放置适当的纹理。


通过此操作,您可以获得非常有用的副作用:显着的纹理压缩。 只需在“视图”中打开它们并导出,将格式更改为heic即可。 平均而言,此简单操作每个模型可节省5兆字节。


另外,如果您要从Internet下载场景,我建议您将其下载到存档中,解压缩并传输解压缩的scn文件的URL。 这将为您和用户节省额外的兆字节-反过来,这将加快下载速度,并减少故障点的数量。 同意:对每个外部资源(甚至在移动Internet上)单独发出请求,并不是提高可靠性的最佳方法。


伤害了我很多


当我开车时,我经常听到宇宙破裂的硬盘驱动器,在下一条街上行驶。
-约翰·卡马克

因此,当加载和导入模型的工作开始进行时,出现了一个新任务:在场景中添加各种效果和功能。 相信我,有话要说。 我们从遍历SceneKit中的各种常量开始。


物理之后会会立即考虑SceneKit中的约束。在渲染框架之前
物理之后会立即考虑SceneKit中的约束。 在渲染框架之前


约束,你说? 什么是常数? 很少有人知道,甚至更多地谈论它,但是SceneKit有自己的常量集。 尽管它们不像UIkit中的常量那样灵活,但是您仍然可以使用它们做很多有趣的事情。


SCNReplicatorConstraint
SCNReplicatorConstraint


让我们从一个简单的常量SCNReplicatorConstraint开始 。 他所做的只是复制带有其他偏移量的另一个对象的位置,旋转和大小。 与所有其他常数一样,他可以更改强度并设置增量标记。 最好在此常数上显示两个参数。


力量降低10倍
力量降低10倍


强度会影响对象施加多少变换。 而且由于目标物体的位置每帧都会变化-阴影物体接近距离差的十分之一。 因此,出现延迟效果。


增量增加,强度降低10倍
增量增加,强度降低10倍


增量性反过来影响常量在渲染后是否被取消。 假设我们将其关闭。 然后我们看到在每个帧上,常量在渲染之前应用,并且在渲染之后被取消,因此重复了每个帧。 结果,将这两个参数组合在一起,您可以获得时钟指针一个相当有趣的效果。


飞机始终面向相机。
飞机始终面向相机。


让我们继续一个更有趣的常量:所谓的广告牌。


假设有必要始终使某个物体“面对”我们。 为此,只需使用SCNBillboardConstraint ,指示对象可以围绕哪些轴旋转。 此外,在计算每一帧之前(在物理步骤之后),将更新所有对象的位置和方向,以满足所有常数。


在这里您可以提及Look At Constraint :它类似于广告牌,只能将对象设置为面对场景中的其他任何对象,而不是当前的摄像机。


他们的帮助下可以做什么? 当然,最常将这些常数用于绘制树木或小物体。 它们还会产生特殊效果,例如起火或爆炸。 另外,在他们的帮助下,您可以使相机跟随舞台上的对象。


保持物体之间的距离
保持物体之间的距离


SCNDistanceConstraint允许设置另一个对象位置的最小和/或最大距离。 是的,您可以用它来做蛇。 :)此约束还可以用于将摄影机绑定到角色,尽管摄影机的位置通常更复杂,仅使用基板来描述它并不是一件容易的事。 通过在物理引擎中添加弹簧也可以达到相同的效果,但是如果需要避免弹簧过度拉伸或压缩的问题,可以对该弹簧进行应变补充。


许多人在某些《杀手》,《辐射》或《天际》中都看到过:您拖动尸体,它碰到了障碍物-并开始表现出恶魔进入的样子。 此常数将有助于避免此类错误。


SCNSlider约束
SCNSlider约束


SCNSliderConstraint允许使用合适的碰撞蒙版设置给定对象与物理物体之间的最小距离。 相当有趣的常数,但是他们再次尝试使用物理交互来模拟它。 主要思想是为没有物理物体的对象设置具有物理物体的盲区半径。


逆运动学在工作
逆运动学在工作


SCNIKConstraint是最有趣的,也是最复杂的常数,它使用所谓的逆运动学。 使用一连串的父节点,逆运动学迭代地尝试将将此常数应用到的节点位置带到必要的位置。 实际上,它使您不必考虑肩膀和前臂应处于哪个位置,而只需设置手的位置以及连接节点的可能旋转角度即可。 其余的将为您计算。 这种限制的主要缺点是,它只允许您设置手的位置,而不能设置其方向,并且可以全局限制角度,而不会破坏轴。


因此,我们详细讨论了常量及其所知道的方法。 让我们继续探索有趣的效果。 我们将处理阴影的影响。


有飞机,但是没有
有飞机,但是没有


在支持阴影的引擎中似乎比创建阴影更容易? 但是有时阴影需要投射在完全透明的平面上。 这在ARKit中非常有用,因为相机图像显示在平面后面,并且阴影应投射在某处。 技巧很简单:首先,您需要启用延迟的阴影并在“材质”选项卡中关闭平面的所有组件中的记录,然后阴影将继续与之重叠。 唯一的问题是该平面将与它后面的对象重叠。


但是阴影并不是SceneKit中唯一研究不足的效果。 让我们现在处理镜子。


SCNF落地镜-可能更简单
SCNF落地镜-可能更简单


每个与SceneKit一起玩的人都可能知道scnfloor,它在地板上增加了镜面反射。 但是由于某种原因,很少有人将其用于真实的镜面反射,因为您可以将模型放置在地板几何图形上,将其稍微倾斜,然后将其变成普通的镜面。



滴在玻璃和弯曲的镜子上
滴在玻璃和弯曲的镜子上


但是,鲜为人知的是,可以为此性别设置法线贴图。 因此,您可以创建许多不同的有趣效果,例如条纹或曲面镜的效果。


紫外线


有一次我睁开眼睛亲吻了一个女孩。 这个女孩用剪裁近的飞机割了她的脸。 从那时起,我只闭着眼睛亲吻。
-约翰·卡马克

阴影,镜子-有趣的效果。 但是,如果巧妙地使用一种效果,结果可能会变得更加有趣-视频纹理。



普通和高度图视频
普通和高度图视频


您可能只需要它们就可以在游戏中显示视频。 但更有趣的是,借助视频纹理,您可以修改几何形状。 为此,您需要将带有高度贴图的视频纹理放入材质的置换属性中,并在具有足够大数量的线段的平面上使用材质。 有待了解如何将其放置在那里。


我在场景创建过程的描述中提到可以将SKScene用作材质属性 ,这是SpriteKit场景。 SpriteKit类似于SceneKit,但用于2D图形。 它支持使用SKVideoNode显示视频。 您只需要将SKVideoNode放在SKScene中,将SKScene放在SCNMaterialProperty中,就可以了。


但是在导出生成的3D场景并将其打开到其他位置后,我们将看到一个黑色正方形。 通过浏览scn文件,我找到了原因。 事实证明,在保存视频代码时,它不会保存视频URL。 似乎您接受并统治。 但是,并不是所有的事情都那么简单:scn文件是所谓的二进制plist,其中包含NSKeyedArchiver的结果。 而且材质(即SpriteKit场景)是同一个二进制plist,事实证明,该二进制plist已经位于另一个二进制plist中! 最好只有两个层次的嵌套。


好了,现在我们将继续介绍效果,但现在转到允许您创建任何效果的工具。 这些是着色器修改器。


修改内容之前,您需要了解我们正在修改的内容。 根据定义,着色器是针对GPU的程序,该程序针对每个顶点和每个像素运行。 因此,着色器是确定对象在屏幕上的外观的程序。


好吧,着色器修改器使您可以将标准着色器的结果更改为GLSL或金属着色语言。 它们也可以在可视编辑器中使用,该编辑器使您可以实时查看修改器中的更改。



毛发和视差贴图
毛发和视差贴图


借助着色器修改器,您可以创建复杂的视觉效果。 例如,几个最著名的效果:毛发和视差贴图


#pragma arguments texture2d bg; texture2d height; float depth; float layers; #pragma transparent #pragma body constexpr sampler sm = sampler(filter::linear, s_address::repeat, t_address::repeat); float3 bitangent = cross(_surface.tangent, _surface.normal); float2 direction = float2(-dot(_surface.view.rgb, _surface.tangent), dot(_surface.view.rgb, _surface.bitangent)); _output.color.rgba = float4(0); for(int i = 0; i < int(floor(layers)); i++) { float coeff = float(i) / floor(layers); float2 defaultCoords = _surface.diffuseTexcoord + direction * (1 - coeff) * depth; float2 adjustment = float2(scn_frame.sinTime + defaultCoords.x, scn_frame.cosTime) * depth * coeff * 0.1; float2 coords = defaultCoords + adjustment; _output.color.rgb += bg.sample(sm, coords).rgb * coeff * (height.sample(sm, coords).r + 0.1) * (1.0 - coeff); _output.color.a += (height.sample(sm, coords).r + 0.1) * (1.0 - coeff); } return _output; 

具有实时焦散的的Ray Casting。
实时焦散的射线投射


更有趣的是,没有人会费心将工作结果全部扔掉并编写自己的渲染器。 例如,您可以尝试在着色器中实现Ray Casting。 所有这些工作速度足够快,即使在如此复杂的计算中也可以提供30 FPS。 但这是单独报告的主题。 快来莫比乌斯吧!


恶梦!


我不喜欢眨眼,因为由于光线不足,闭合的眼睑会为BDPT急剧加载GPU。
-约翰·卡马克

因此,我们有一堆效果很酷的物体。 现在仍然需要学习如何记录它们。 为此,让我们继续讨论一个更复杂的主题:如何学习如何直接从SceneKit中录制视频而无需外部UI,以及如何优化录制数十次。


让我们首先转向最简单的解决方案: ReplayKit 。 找出为什么它不合适。 一般来说,此解决方案使您可以用几行代码创建一个屏幕条目,并通过系统预览保存它。 但是 它的缺点很大-它记录了所有内容,整个UI,包括屏幕上的所有按钮。 这是我们的第一个决定,但出于明显的原因,不可能将其投入生产:用户必须共享视频,而不能从系统预览中共享视频。


我们发现自己处于需要从头开始编写解决方案的情况。 完全从头开始。 因此,让我们看看如何在iOS中创建自己的视频并在那里录制帧。 一切都非常简单:


录音过程
录音过程


我们需要创建一个实体来记录文件-AVAssetWriter ,向其中添加视频流-AVAssetWriterInput ,并为此流创建一个适配器,该适配器会将像素缓冲区转换为流所需的格式-AVAssetWriterPixelBufferAdaptor


以防万一,我提醒您, 像素缓冲区是一个实体,这是一块内存,其中以某种方式写入了像素数据。 这本质上是图片的低级表示。


但是如何获得此像素缓冲区? 解决方案很简单。 SCNView有一个很棒的.snapshot()函数,它返回UIImage。 我们只需要从该UIImage创建一个像素缓冲区。


 var unsafePixelBuffer: CVPixelBuffer? CVPixelBufferPoolCreatePixelBuffer(NULL, self.pixelBufferPool, &unsafePixelBuffer) guard let pixelBuffer = maybePixelBuffer else { return } CVPixelBufferLockBaseAddress(pixelBuffer, 0) let data = CVPixelBufferGetBaseAddress(pixelBuffer) let rgbColorSpace = CGColorSpaceCreateDeviceRGB() let bitmapInfo = CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue) let rowBytes = NSUInteger(CVPixelBufferGetBytesPerRow(pixelBuffer)) let context = CGContext( data: data, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), space: rgbColorSpace, bitmapInfo: bitmapInfo.rawValue ) context?.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) CVPixelBufferUnlockBaseAddress(pixelBuffer, 0) self.appendPixelBuffer(pixelBuffer, withPresentationTime: presentationTime) 

我们只是在内存中分配一个位置,描述这些像素的格式,阻塞缓冲区以进行更改,获取内存地址,在接收到的地址处创建上下文,我们在其中描述像素的包装方式,图片中的行数以及使用的色彩空间。 然后,我们从那里的UIImage复制像素,知道最终格式,然后解锁更改。



现在,您需要在每一帧中执行此操作。 为此,我们创建了一个显示链接,该链接将为每个帧调用一个回调,然后依次调用快照方法并从图像创建像素缓冲区。 一切都很简单!



但是没有 即使在功能强大的电话上,这种解决方案也会导致严重的延迟和FPS下降。 让我们进行优化。



假设我们不需要60 FPS。 我们甚至会对25日感到满意。 但是,实现此结果的最简单方法是什么? 当然,您只需要将所有这些都放在后台线程上即可。 而且,根据开发人员的说法,此功能是线程安全的。



嗯,延迟有所减少,但视频已停止录制...


一切都很简单。 就像他们说的那样,如果您有问题,并且将在多个线程的帮助下解决该问题,那么您将遇到两个问题。


如果您尝试记录的时间戳小于最后一次记录的时间戳的像素缓冲区,则整个视频将无效。



然后,让我们在之前的写入操作结束之前再写一个新的缓冲区。



嗯,好多了。 但是都一样,为什么滞后最初会出现?



事实证明, .snapshot()函数可用来从屏幕上获取图像,为每个调用创建一个新的渲染器,从头开始绘制一个帧并返回它,而不是返回屏幕上的图像。 这会带来有趣的效果。 例如,物理仿真的速度是原来的两倍。


但是,等等-为什么我们每次尝试渲染一个新帧? 当然,您可以在屏幕上找到显示的缓冲区。 确实,可以访问这样的缓冲区,但这是非常重要的。 我们需要从Metal获取CAMetalDrawable


不幸的是,出于相当容易理解的原因,直接从SCNView进入Metal并不是一件容易的事-在SceneKit中,您可以自己选择API类型,但是如果您深入研究并查看图层 ,就可以看到它的作用,对于Metal而言, CAMetalLayer


但是,失败也在等待着我们:在CAMetalLayer中,与视图进行交互的唯一方法是nextDrawable函数,该函数返回一个未占用的CAMetalDrawable。 可以理解的是,您将向其中写入数据并在其上调用当前函数,该函数将在屏幕上显示该函数。


解决方案实际上存在。 事实是,从屏幕上消失后,缓冲区不会被释放,而只会放回池中。 确实,为什么要在两个或三个缓冲区足够的情况下每次分配内存:一个缓冲区显示在屏幕上,第二个缓冲区用于渲染,第三个缓冲区用于后处理(如果有)。


事实证明,显示缓冲区后,缓冲区中的数据不会消失在任何地方,您可以安全地访问它们。


而且,如果我们在后继者中开始响应对nextDrawable()的每次调用进行保存,则几乎可以得到所需的东西。 问题在于,保存的CAMetalDrawable是当前正在其中绘制图像的CAMetalDrawable。


跳转到实际解决方案非常简单-我们既保存了当前Drawable,又保存了前一个。


现在就准备好-通过CAMetalDrawable直接访问内存。


 var unsafePixelBuffer: CVPixelBuffer? CVPixelBufferPoolCreatePixelBuffer(NULL, self.pixelBufferPool, &unsafePixelBuffer) guard let pixelBuffer = maybePixelBuffer else { return } CVPixelBufferLockBaseAddress(pixelBuffer, 0) let data = CVPixelBufferGetBaseAddress(pixelBuffer) let width: NSUInteger = lastDrawable.texture.width let height: NSUInteger = lastDrawable.texture.height let rowBytes: NSUInteger = NSUInteger(CVPixelBufferGetBytesPerRow(pixelBuffer) lastDrawable.texture.getBytes( data, bytesPerRow: rowBytes, fromRegion: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0 ) CVPixelBufferUnlockBaseAddress(pixelBuffer, 0) self.appendPixelBuffer(pixelBuffer, withPresentationTime: presentationTime) 

因此,现在我们不创建上下文并在其中绘制UIImage,而是将一个内存复制到另一个。 问题出现了:像素格式如何?


它与deviceColorSpace不匹配...并且与常用颜色空间不匹配...


这正是执行同一任务的公共壁炉之一作者崩溃的地方 。 其他人甚至都没有来到这里。



好吧,所有这些技巧-为了使用令人​​毛骨悚然的过滤器?


好吧,不! 在有关ARKit的文章中,您会提到相机的图像不使用标准色彩空间,而是经过扩展。 甚至提供了色彩空间转换矩阵。 但是,如果您可以尝试直接以这种格式记录,那么为什么要进行转换呢? 仍然需要找出60种可用格式中的哪种格式...



然后我开始破产。 我以不同的格式以不同的流录制了三个视频,并用每次录制替换了它们。


结果,我们得到了大约第四十种格式的名称。 原来是kCVPixelFormatType_30RGBLEPackedWideGamut 。 我怎么没猜到



但是我的喜悦一直持续到第一个测试者。 我没话说 怎么了 我只是花了很多时间寻找正确的格式。 问题很快就可以找到好地方-稳定地复制了该错误,并且仅在6s和6s Plus上才可以。 在那之后,我几乎立刻记得,只有第7部iPhone才开始安装具有广色域支持的显示器。


将广色域更改为旧的32RGBA,我得到了工作记录! 仍然有待了解如何确定设备是否支持宽色域。 iPad有不同类型的显示器,我想当然可以从系统中获取ENUM类型的显示器。 翻阅文档,我发现它-这是UITraitCollection中的displayGamut


将组装件交给测试人员后,我收到了来自他们的喜讯-一切正常,即使在旧设备上也没有任何延迟!


最后,我想告诉您-做3D图形! 在我们的应用中,增强现实并不是主要的用例,在周末,人们旅行了2,000多公里,观看了3,000多个对象,并与他们一起录制了1,000多个视频! 想象一下,如果自己做,该怎么办。

Source: https://habr.com/ru/post/zh-CN431880/


All Articles