基於CoreGraphics的3D渲染方案

handyTool發表於2017-12-23

前言

今年的首要研究物件OpenGL基本研究的差不多了,突發奇想,想用CoreGraphics根據OpenGL的渲染流水線,渲染出3D圖形來。折騰了2天,寫出了個demo,效果如下。

基於CoreGraphics的3D渲染方案

其實這種通過2D渲染引擎渲染3D的技術方案在Flash時代我就聽說了,但是當時對於3D技術不是很瞭解,並沒有做深入研究。

原理

在OpenGL中,每個頂點通過Vertex Shader的處理,被處理成基本的繪製圖形,比如三角形,再通過Fragment Shader處理各個畫素點的顏色。最終以透視的效果呈現在螢幕上(當然如果你用了正交矩陣就沒透視啦)。根據上面的原理,我在渲染方案中定義了兩種基本圖形,線和多邊形。線由2個頂點組成,多邊形由3到多個頂點組成。通過MVP矩陣對頂點進行變換,然後用CoreGraphics繪製頂點變換後的圖形。上圖中的正方體就是由6個四邊形組成,錐體則是4個三角形組成。

基本繪製圖形

每個基本繪製圖形都會實現下面的協議,material是圖形的樣式,包括顏色,線條粗細等,transform方法用來對組成圖形的頂點進行變換,並返回變換後的圖形。sortZRef會返回圖形在z方向排序的參考值,這個主要用來彌補CoreGraphics中無法進行Depth Test的缺陷。不過目前的參考值計算方案還是有問題的,僅僅是計算了所有頂點變換後z的平均值而已。

public protocol HT3DElement {
    var material: HT3DMaterial { get set }
    func transform(matrix: GLKMatrix4) -> Self
    func sortZRef() -> Float
}
複製程式碼

下面我們來看看圖形-線的實現。

public var startPoint: GLKVector3
public var endPoint: GLKVector3
public var material: HT3DMaterial
public func transform(matrix: GLKMatrix4) -> HT3DLineElement {
    let newStartPoint = matrix * GLKVector4.init(vector3: startPoint, w: 1)
    let newEndPoint = matrix * GLKVector4.init(vector3: endPoint, w: 1)
    return HT3DLineElement.init(startPoint: (newStartPoint / newStartPoint.w).xyz, endPoint:
(newEndPoint
            / newEndPoint.w).xyz, material: material)
}
public func sortZRef() -> Float {
    return (startPoint.z + endPoint.z) / 2.0
}
複製程式碼

在使用矩陣對頂點變換後,要重新把頂點變換到螢幕空間,所以將頂點除以它的w。xyz是自定義的擴充套件,獲取4維向量的前3維。

(newStartPoint / newStartPoint.w).xyz
複製程式碼

sortZRef的實現正如上面所說,求z的平均值。

幾何體Geometry

Geometry由一組基本圖形組成,比如一個正方體。Geometry提供modelMatrix對這一組基本圖形進行變換。它的存在讓我們可以為每一組基本圖形提供不同的模型變換。同時它也肩負著管理圖形材質的任務。可以通過它的setMaterialForElementsInRange方法為每一個基本圖形設定不同的樣式。

public var elements: [HT3DElement]?
public var materials: [HT3DMaterial] = []
public var modelMatrix: GLKMatrix4 = GLKMatrix4Identity
public var material: HT3DMaterial? {
    return materials.first
}
public init(elements: [HT3DElement], material: HT3DMaterial) {
    self.elements = elements
    self.materials.append(material)
    self.setMaterialForElementsInRange(range: Range<Int>.init(uncheckedBounds: (0,
elements.count - 1)), materialIndex: 0)
}

public func setMaterialForElementsInRange(range: Range<Int>, materialIndex: Int) {
    if let material = materials[cycle: materialIndex] {
        for index in range.lowerBound...range.upperBound {
            if let element = self.elements?[safe: index] {
                var ele = element
                ele.material = material
                self.elements?[index] = ele
            }
        }
    }
}
複製程式碼

CoreGraphics渲染

為了方便其他渲染器的實現,我定義了渲染器的協議。渲染器的主要功能就是渲染基本圖形的集合。

protocol HT3DRenderContext {
    func render(elements: [HT3DElement])
}
複製程式碼

為了更加方便的呼叫渲染器程式碼,為該協議編寫了下面的擴充套件方法。

extension HT3DRenderContext {
    public func render(vpMatrix: GLKMatrix4, geometries: [HT3DGeometry]) {
        var elements: [HT3DElement] = []
        for geometry in geometries {
            let _ = geometry.elements?.map {
                elements.append($0.transform(matrix: vpMatrix * geometry.modelMatrix))
            }
        }
        elements.sort { $0.sortZRef() > $1.sortZRef() }
        render(elements: elements)
    }
}
複製程式碼

這樣就可以很方便的使用VP(ProjectionMatrix * ViewMatrix)和幾何體列表渲染了。在這個方法中,我們將基本圖形的頂點使用VP和所屬幾何體的ModelMatrix進行變換,然後將這些基本圖形按照z軸排序,從而模擬DepthTest,最後呼叫渲染器的渲染方法。這個方法的具體實現取決於你用什麼樣的渲染器。本文自然採用了CoreGraphics渲染器。渲染器程式碼在HT3DCGRenderContext.swift中。主要就是線和多邊形兩種基本圖形的渲染,非常簡單的程式碼。

func renderElement(context: CGContext, element: HT3DLineElement) {
    context.setStrokeColor(UIColor.fromVec3(glkVector3: element.material.lineColor).cgColor)
    context.setLineWidth(element.material.lineWidth)
    context.beginPath()
    context.move(to: convertCoordFromGLToCG(element.startPoint.cgPoint()))
    context.addLine(to: convertCoordFromGLToCG(element.endPoint.cgPoint()))
    context.strokePath()
}

func renderElement(context: CGContext, element: HT3DPolygonElement) {
    context.setFillColor(UIColor.fromVec3(glkVector3: element.material.diffuse).cgColor)
    context.setStrokeColor(UIColor.fromVec3(glkVector3: element.material.lineColor).cgColor)
    context.setLineWidth(element.material.lineWidth)

    context.beginPath()

    element.points.first.map { context.move(to: convertCoordFromGLToCG($0.cgPoint())) }
    for index in 1..<element.points.count {
        context.addLine(to: convertCoordFromGLToCG(element.points[index].cgPoint()))
    }
    if element.isClosed {
        context.closePath()
    }

    context.drawPath(using: .fillStroke)
}

func convertCoordFromGLToCG(_ from: CGPoint) -> CGPoint {
    return (from * (1, -1) + 1.0) * 0.5 * (canvasSize.width, canvasSize.height)
}
複製程式碼

其中convertCoordFromGLToCG用於將OpenGL座標轉換成CoreGraphics中的座標,如果你對OpenGL座標不瞭解,可以去看我的OpenGL系列教程

如果你對本文的其他關於OpenGL的概念也不理解,也可以去教程裡面找找答案,畢竟本文中很多OpenGL的概念我只是一筆帶過。

整合

最後將CoreGraphics渲染器用Layer封裝起來,程式碼在HT3DCGLayer中。比較簡單,有興趣的同學可以直接前往GitHub檢視,如果覺得專案還湊合,別忘了Star一下哦~

目前這個方案還只是開始階段,還有很多優化和不足的地方有待改進。比如使用更加精準的z軸排序演算法,提供基本光照模型等等。

相關文章