直播间 3d讲鞋
需求背景:
3d 讲鞋属于一种新颖的直播玩法,用于解决某些主播需要讲解部分鞋类商品,却没有实物来做介绍的问题。广义上讲,它也是增强现实的一种应用实现,将虚拟的球鞋模型与真实的主播融合在一起,必能给直播间的观众焕然一新的观看体验。
需要的功能:
需求需要实现以下几个问题:
- 球鞋模型的实时渲染,附加简单的特效
- 支持简单交互,如旋转、平移、缩放
- 渲染后的模型叠加到直播间流数据
以上操作需要在一个渲染帧内实时处理完成,否则会造成直播间的卡顿问题
球鞋渲染:
我们需要将一个obj 带纹理的模型渲染到一个直播间的view上,需要实现这一步,目前 iOS 端可以选择的方案有很多:
- OpenGL
- SpriteKit
- Metal
为了节约内存,这里选择使用 Metal 来实现。
使用 Metal 的过程大约分如下几步:
- 创建 MTKView,并初始化设置颜色等参数(例如设置view.isOpaque = false,来实现透明背景)
- 加载 shader 文件,设置 Uniforms 变量,因为我们需要做一些简单交互和特效处理,我们需要将操作矩阵和模型矩阵等数据传给 shader
- 加载 obj 模型数据和图片纹理数据
- 渲染绘制,这一步主要是在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)
}
如此,便完成了流合并的过程。
优化
通过上面的过程,基本实现了将球鞋模型合并到直播流里面,呈现给直播间的用户的功能,然而实际运行后发现卡顿严重,主播端几乎无法正常使用。需要做部分优化
预加载
两处做了预加载
- 为了提高模型加载速度,可提前加载模型和纹理数据
- 提前加载需要使用的滤镜,并可重复使用
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
既已览卷至此,何不品评一二: