需求背景:

3d 讲鞋属于一种新颖的直播玩法,用于解决某些主播需要讲解部分鞋类商品,却没有实物来做介绍的问题。广义上讲,它也是增强现实的一种应用实现,将虚拟的球鞋模型与真实的主播融合在一起,必能给直播间的观众焕然一新的观看体验。

需要的功能:

需求需要实现以下几个问题:

  1. 球鞋模型的实时渲染,附加简单的特效
  2. 支持简单交互,如旋转、平移、缩放
  3. 渲染后的模型叠加到直播间流数据

以上操作需要在一个渲染帧内实时处理完成,否则会造成直播间的卡顿问题

球鞋渲染:

我们需要将一个obj 带纹理的模型渲染到一个直播间的view上,需要实现这一步,目前 iOS 端可以选择的方案有很多:

  1. OpenGL
  2. SpriteKit
  3. Metal

为了节约内存,这里选择使用 Metal 来实现。

使用 Metal 的过程大约分如下几步:

  1. 创建 MTKView,并初始化设置颜色等参数(例如设置view.isOpaque = false,来实现透明背景)
  2. 加载 shader 文件,设置 Uniforms 变量,因为我们需要做一些简单交互和特效处理,我们需要将操作矩阵和模型矩阵等数据传给 shader
  3. 加载 obj 模型数据和图片纹理数据
  4. 渲染绘制,这一步主要是在draw(in view: MTKView) 里做一些必要的操作

亮度调节

由于给定的模型偏暗,需要主动提高环境亮度,所谓“天黑了就开灯”,只需要在三维场景里添加合适的光源即可,这里通过 shader 来实现:

#include <metal_stdlib>
using namespace metal;

// 光源颜色白色
constant float3 kSpecularColor= { 1, 1, 1 };
// 光源强度
constant float kSpecularPower = 80;

......

// 光源属性  
struct Light {
    float3 direction;
    float3 ambientColor;
    float3 diffuseColor;
    float3 specularColor;
};

// 各种镜面反射、漫反射参数
constant Light light = {
    .direction     = { 0.13, 0.72, 0.68 },
    .ambientColor  = { 0.05, 0.05, 0.05 },
    .diffuseColor  = { 1, 1, 1 },
    .specularColor = { 0.2, 0.2, 0.2 }
};

fragment float4 main_fragment(VertexProjected  v              [[stage_in]],
                              texture2d<float> diffuseTexture [[texture(0)]],
                              sampler          samplr         [[sampler(0)]]) {
    float3 const diffuseColor = diffuseTexture.sample(samplr, v.texCoords).rgb;
    
    int flag = 2;
    if (flag == 4) {
        return float4(diffuseColor, 1);
    }
    //
    float3 const ambientTerm = light.ambientColor * diffuseColor;

    float3 const normal = normalize(v.normal);
    float const diffuseIntensity = saturate(dot(normal, light.direction));
    float3 const diffuseTerm = light.diffuseColor * diffuseColor * diffuseIntensity;

    float3 specularTerm(0);
    if (diffuseIntensity > 0) {
        float3 const eyeDirection = normalize(v.eyePosition);
        float3 const halfway = normalize(light.direction + eyeDirection);
        float specularFactor = pow(saturate(dot(normal, halfway)), kSpecularPower);
        specularTerm = light.specularColor * kSpecularColor * specularFactor;
    }

    float4 r = float4(ambientTerm + diffuseTerm + specularTerm, 1);
    // 主动提高亮度
    r[0] = sqrt(r[0]);
    r[1] = sqrt(r[1]);
    r[2] = sqrt(r[2]);
    
    return r;
}

四元数实现轨迹球

轨迹球

在三维笛卡尔坐标系下,通常用 44 矩阵来描述物理模型的位置和朝向。为了将主播在手机(二维)屏幕的交互转换成 44 矩阵,需要实现一个轨迹球功能,其目的就是将手机屏幕的平面 xy 上的主播手指对应坐标转换为半径为 r ,球心为原点的球面上的一点,以轨迹球圆心为起点,手指对应在球面的坐标为终点得到向量,这样当手指移动时,可以通过计算向量之间的夹角和旋转轴,来推出 4*4 的模型变换矩阵。

其中手指坐标 point 映射到轨迹球上的方法如下:

func mapToSphere(_ point: CGPoint) -> [Float] {
    let w = UIScreen.main.bounds.width
    let h = UIScreen.main.bounds.height
    let adjustWidth: CGFloat = 1.0/((w-1.0)*0.5) // 归一到0-1的范围
    let adjustHeight: CGFloat = 1.0/((h-1.0)*0.5)
    var tmpPoint: CGPoint = CGPoint()
    tmpPoint.x = (point.x*adjustWidth)-1.0
    tmpPoint.y = 1.0-(point.y*adjustHeight) // iOS 屏幕坐标 y 轴反向
    let length = (tmpPoint.x*tmpPoint.x)+(tmpPoint.y*tmpPoint.y)
    var tmpVec3: [Float] = [Float](repeating: 0.0, count: 3)
    if length>1.0 {
        tmpVec3[0] = Float(tmpPoint.x/sqrt(length))
        tmpVec3[1] = Float(tmpPoint.y/sqrt(length))
        tmpVec3[2] = 0.0
    } else {
        tmpVec3[0] = Float(tmpPoint.x)
        tmpVec3[1] = Float(tmpPoint.y)
        tmpVec3[2] = Float(sqrt(1.0-length))
    }
    return tmpVec3
}

四元数与三维旋转

通过向量来计算模型矩阵,可以使用四元数来实现计算:

// 求得轨迹球旋转四元数
func getQuaternion(_ startVec3: [Float], endVec3: [Float]) -> [Float] {
    var rotQuaternion: [Float] = [Float](repeating: 0.0, count: 4)
    rotQuaternion[0] = (startVec3[1]*endVec3[2])-(startVec3[2]*endVec3[1])
    rotQuaternion[1] = (startVec3[2]*endVec3[0])-(startVec3[0]*endVec3[2])
    rotQuaternion[2] = (startVec3[0]*endVec3[1])-(startVec3[1]*endVec3[0])
    var length = rotQuaternion[0]*rotQuaternion[0]+rotQuaternion[1]*rotQuaternion[1]
    length = length+rotQuaternion[2]*rotQuaternion[2]
    if length > 0.0 {
        rotQuaternion[3] = (startVec3[0]*endVec3[0]) + (startVec3[1] * endVec3[1]) + (startVec3[2] * endVec3[2])
    } else {
        rotQuaternion[0] = 0.0
        rotQuaternion[1] = 0.0
        rotQuaternion[2] = 0.0
        rotQuaternion[3] = 0.0
    }
    return rotQuaternion
}

// 求得轨迹球旋转矩阵
func getRotationMatrix(_ rotQuaternion: [Float]) -> float4x4 {
    let x = rotQuaternion[0]
    let y = rotQuaternion[1]
    let z = rotQuaternion[2]
    let w = rotQuaternion[3]
    let x2 = x * x
    let y2 = y * y
    let z2 = z * z
    let xy = x * y
    let xz = x * z
    let yz = y * z
    let wx = w * x
    let wy = w * y
    let wz = w * z
    
    let m00: Float = 1.0-2.0*(y2+z2)
    let m01: Float = 2.0*(xy-wz)
    let m02: Float = 2.0*(xz+wy)
    let m03: Float = 0.0
    let m10: Float = 2.0*(xy+wz)
    let m11: Float = 1.0-2.0*(x2+z2)
    let m12: Float = 2.0*(yz-wx)
    let m13: Float = 0.0
    let m20: Float = 2.0*(xz-wy)
    let m21: Float = 2.0*(yz+wx)
    let m22: Float = 1.0-2.0*(x2 + y2)
    let m23: Float = 0.0
    let m30: Float = 0.0
    let m31: Float = 0.0
    let m32: Float = 0.0
    let m33: Float = 1.0
    let rotMat = float4x4([m00, m01, m02, m03], [m10, m11, m12, m13], [m20, m21, m22, m23], [m30, m31, m32, m33])
    return rotMat
    
}

合并直播流

完成模型的实时渲染显示后,结下来要做的就是将渲染的每一帧图像(CIImage)合并到直播流(CVPixelBuffer)里,之后在推流给服务器。我们需要在字节推流的回调函数 captureDidProcess 里完成图像的合成,其中通过 metalView 获取 CIImage 的方法如下:

if let tv = metalView.currentDrawable?.layer.nextDrawable()?.texture,
let cimg = CIImage(mtlTexture: tv, options: nil) {
    self.sImage = cimg
}

接下来通过滤镜做变换和合并:

let overCompositingf = CIFilter(name: "CISourceOverCompositing") // 用于图像合成
let scalef = CIFilter(name: "CILanczosScaleTransform") // 用于缩放

......

var scale = Float(CVPixelBufferGetWidth(pixelBuffer)) /  Float(self.metalView.drawableSize.width)
if scale <= 0 {
    scale = 1.0
}

// 缩放
scaleFilter.setValue(my3DTempImage, forKey: kCIInputImageKey)
scaleFilter.setValue(NSNumber(value: scale), forKey: "inputScale")
guard let outputImage = scaleFilter.outputImage else {
    return
}

// 合成
let transformOutPut = CIImage(cvPixelBuffer: pixelBuffer)
compositingFilter.setValue(transformOutPut, forKey: kCIInputBackgroundImageKey)
compositingFilter.setValue(outputImage, forKey: kCIInputImageKey)
if let result = compositingFilter.outputImage {
    context.render(result, to: pixelBuffer)
}

如此,便完成了流合并的过程。

优化

通过上面的过程,基本实现了将球鞋模型合并到直播流里面,呈现给直播间的用户的功能,然而实际运行后发现卡顿严重,主播端几乎无法正常使用。需要做部分优化

预加载

两处做了预加载

  1. 为了提高模型加载速度,可提前加载模型和纹理数据
  2. 提前加载需要使用的滤镜,并可重复使用

MetalView转CIImage 优化

通过时长 log 可以发现其实在推流的每帧回调 captureDidProcess 里来转换得到 CIImage 是不合理的,转换的过程严重阻塞推流的过程,最终导致主播段严重卡顿。为此可以将 MetalView->CIImage 的过程移到别的地方。一个有效的方法是可在 metalView 渲染管线的 draw(in:) 方法中,顺带生成对应的快照,并存下来:

func draw(in view: MTKView) {
    ......
    if let tv = view.currentDrawable?.layer.nextDrawable()?.texture,
    let cimg = CIImage(mtlTexture: tv, options: nil) {
        self.sImage = cimg
    }
}

这样在 captureDidProcess 里只需要获取一下 sImage ,再做后续合流的操作即可。

#

相关文献:

四元数与三维旋转: https://krasjet.github.io/quaternion/quaternion.pdf