iOS ARKit 教程:不觸摸屏幕,用空氣中的手勢作畫

iOS ARKit 教程:不觸摸屏幕,用空氣中的手勢作畫



本文翻譯自 iOS ARKit Tutorial: Drawing in the Air with Bare Fingers,原作者是 Osama AbdelKarim AboulHassan。

最近,Apple 發布了名為 ARKit 的全新增強現實(AR)庫。在許多人看來,這只是另一個好的 AR 庫而已,而不是什么值得關注的革命性技術。但如果你了解過去幾年 AR 的發展,就不會如此草率地下結論。

在本文中會用 iOS ARKit 創建一個好玩的項目。用戶把手指放在桌子上,就好像握著一只筆,點擊拇指甲就可以開始繪畫。完成后,用戶還可以把畫作轉成 3D 對象,就像下面的動圖展示的那樣。此項目的完整源碼可以在 GitHub 上下載。

動圖

為何現在要關注 ARKit?

每個有經驗的開發者應該都知道 AR 不是什么新概念了。AR 的第一次大規模開發要追溯到網絡攝像頭剛開始應用的時期。那時的 app 通常用于對臉做一些變化。然而,人們很快就發現把臉變成兔子并不是什么迫切的需求,很快這波勢頭就降下去了!

我相信 AR 一直以來都有兩個關鍵技術沒有實現,導致它沒那么實用:可用性和沉浸性。如果你觀察過其它有關 AR 的不實鼓吹,就會發現這兩點。舉個例子,當開發者可以訪問手機攝像頭的時候,就出現了一波對 AR 的鼓吹。除了強勢回歸的偉大的變兔子工具之外,還有一波 app 可以把 3D 對象放到打印的二維碼上。但這個概念從來從來都沒有火過。這并不是增強現實,只是增強的二維碼而已。

然后 Google 用一次科技神話震驚了我們,Google Glass。兩年過去,這個神奇的產品本應來到了我們的生活,但現實卻是已經死掉了!許多批評家分析 Google Glass 失敗的原因,歸咎于從社會角度到 Google 發布產品時的無聊方式等等方面。但在本文中,我們只關心一個原因 —— 在環境中的沉浸性。雖然 Google Glass 解決了可用性問題,但它仍然只是在空氣中繪制 2D 圖像而已。

像微軟、Facebook 和 Apple 這樣的科技泰斗都從這次深刻的教訓中吸取了經驗。2017 年七月,Apple 發布了美妙的 iOS ARKit 庫,制造沉浸性成為了它的優先任務。需要舉著手機使用對用戶體驗仍然有很大的影響,但 Google Glass 的教訓告訴我們,硬件不是問題。

我相信很快就要進入一波新的 AR 熱潮,并在在這個關鍵節點上,它可能會最終找到的合適的市場。歷史課就上到這里,下面開始寫代碼,實際了解 Apple 的增強現實!

ARKit 的沉浸功能

ARKit 提供了兩個主要功能;第一個是 3D 空間里的相機位置,第二個是水平面檢測。前者的意思是,ARKit 假定用戶的手機是在真實的 3D 空間里移動的攝像機,所以在任意位置放置 3D 虛擬對象都會錨定在真實 3D 空間中對應的點上。對于后者來說,ARKit 可以檢測諸如桌子這樣的水平面,然后就可以在上面放置對象。

那么 ARKit 是怎么做到的呢?這是一項叫做視覺慣性里程計(VIO)的技術。不要擔心,就像創業者樂于人們發現他們的創業公司名稱背后的秘密一樣,研究人員也會樂于人們破譯他們命名的發明中的所有術語——所以讓他們開心吧,我們繼續往前看。

VIO 這項技術融合了攝像頭幀畫面和運動傳感器來追蹤設備在 3D 空間里的位置。從攝像頭幀畫面中追蹤運動是通過檢測特征點實現的,也可以說是高對比度圖像中的邊緣點——就像藍色花瓶和白色桌子之間的邊緣。通過檢測兩幀畫面間特征點的相對移動距離,就可以估算出設備在 3D 空間里的位置。所以如果用戶面對一面缺少特征點的白墻,或者設備移動過快導致畫面模糊,ARKit 都會無法正常工作。

上手 iOS 中的 ARKit

寫作本文時,ARKit 是 iOS 11 的一部分,仍然在 beta 版本。所以要上手的話,你需要在 iPhone 6s 或更新的設備上下載 iOS 11 Beta,當然還有新的 Xcode Beta。我們可以用 New > Project > Augmented Reality App 來新建一個 ARKit 項目。但是我發現使用官方 Apple ARKit 示例開始會更方便,它提供了一些必要的代碼塊,尤其對于平面檢測很有幫助。所以,從這個示例代碼開始吧,我會首先解析里面的關鍵點,然后將其修改為我們自己的項目。

首先,我們要確定使用哪個引擎。ARKit 可用于 Sprite SceneKit 或 Meta。在 Apple ARKit 示例里,我們是用的是 iOS SceneKit,由 Apple 提供的 3D 引擎。接下來,我們需要設置用于渲染 3D 對象的視圖。添加一個 ARSCNView 類型的視圖即可。

ARSCNView 是 SceneKit 主視圖 SCNView 的子類,但它擴展了一些有用的功能。它會將設備攝像頭的實時視頻流渲染為場景背景,并會自動匹配 SceneKit 空間和真實世界,假定設備是這個世界里的移動 camera。

ARSCNView 本身不會做 AR 處理,但它需要 AR session 對象來管理設備攝像頭和運動處理。所以,從賦值一個新的 session 開始:

self.session = ARSession()

sceneView.session = session

sceneView.delegate = self

setupFocusSquare()

上面的最后一行代碼添加了一個視覺指示,讓用戶直觀地了解平面檢測狀態。Focus Square 是示例代碼提供的,而不是 ARKit 庫,這也是我們用示例代碼上手的重要原因之一。在示例代碼里的 readme 文件里可以找到更多信息。下面這張圖顯示了映射在桌子上的 focus square:

下一步是啟動 ARKit session。每次 view appears 時都要重啟 session,因為停止追蹤用戶后,之前的 session 信息就沒有價值了。所以,在 viewDidAppear 里啟動 session:

override func viewDidAppear(_ animated: Bool) {

let configuration = ARWorldTrackingSessionConfiguration()

configuration.planeDetection = .horizontal

session.run(configuration, options: [.resetTracking, .removeExistingAnchors])

}

在上面的代碼里,設置了 ARKit session configuration 來檢測平面。寫作本文時,Apple 沒有提供除此以外的選項。但很明顯,這暗示了未來可以檢測到更復雜的對象。然后,開始運行 session 并確保重置了追蹤。

最后,我們需要在攝像頭位置(即實際的設備角度和位置)改變時更新 Focus Square。可以在 SCNView 的 renderer delegate 函數里實現,每次 3D 引擎將要渲染新的幀時都會調用:

func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {

updateFocusSquare()

}

此時運行 app,就可以看見攝像頭視頻流中位于檢測到的水平面上的 focus square 了。在下一個部分,我們解釋平面是如何被檢測到的,以及如何對應放置 focus square。

平面檢測

ARKit 可以檢測新平面,更新現有平面,或是移除它們。為了便于處理平面,我們會創建一些虛擬的 SceneKit node 來管理平面的位置信息以及對 focus square 的引用。平面是定義在 X 和 Z 方向上的,Y 則是表面的法線,也就是說,如果想在平面上繪制一個 node 的話,應保持該 node 的 Y 值與平面相同。

平面檢測是通過 ARKit 提供的回調函數來完成的。舉個例子,下面的回調函數會在每次檢測到新平面時調用:

var planes = [ARPlaneAnchor: Plane]()

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {

if let planeAnchor = anchor as? ARPlaneAnchor {

serialQueue.async {

self.addPlane(node: node, anchor: planeAnchor)

self.virtualObjectManager.checkIfObjectShouldMoveOntoPlane(anchor: planeAnchor, planeAnchorNode: node)

}

}

}

func addPlane(node: SCNNode, anchor: ARPlaneAnchor) {

let plane = Plane(anchor)

planes[anchor] = plane

node.addChildNode(plane)

}

...

class Plane: SCNNode {

var anchor: ARPlaneAnchor

var focusSquare: FocusSquare?

init(_ anchor: ARPlaneAnchor) {

self.anchor = anchor

super.init()

}

...

}

回調函數給我們提供了兩個參數,anchor 和 node。node 是一個普通的 SceneKit node,角度和位置與平面完全相同。它沒有幾何體,所以是可不見的。我們用它來添加自己的平面 node,同樣也是不可見的,但會管理 anchor 里有關平面角度和位置的信息。

所以位置和角度是如何存儲在 ARPlaneAnchor 中的呢?位置、角度和比例都被編碼在 4x4 矩陣中。如果我可以讓你學會一個數學概念的話,毫無疑問就是矩陣了。不過沒關系,可以把 4x4 矩陣想象為:一個包含 4x4 浮點數字的 2D 智能 2D 數組。用某種特定的方式將這些數字乘以它在局部空間中的 3D 頂點 v1 就會得到新的 3D 頂點 v2,即 v1 在世界空間中的表示。所以,如果局部空間里的 v1 = (1, 0, 0),并且希望把它放在世界空間中 x = 100 的位置,相對于世界空間的 v2 就會等于 (101, 0, 0)。當然,如果還要添加繞軸旋轉,背后的數學就會變得更加復雜,但好消息是我們沒必要理解這背后的原理(我強烈建議看看這篇文章中的相關部分,里面有關于此概念的深入解釋)。

checkIfObjectShouldMoveOntoPlane 會檢查是否已經繪制了對象,以及有沒有對象的 y 坐標匹配新檢測到的平面。

現在,回到上一部分描述的 updateFocusSquare()。我們想要保證 focus square 在屏幕中心,并映射到檢測到的距離最近的平面上。使用如下代碼實現:

func updateFocusSquare() {

let worldPos = worldPositionFromScreenPosition(screenCenter, self.sceneView)

self.focusSquare?.simdPosition = worldPos

}

func worldPositionFromScreenPosition(_ position: CGPoint, in sceneView: ARSCNView) -> float3? {

let planeHitTestResults = sceneView.hitTest(position, types: .existingPlaneUsingExtent)

if let result = planeHitTestResults.first {

return result.worldTransform.translation

}

return nil

}

sceneView.hitTest 會搜索對應屏幕上的 2D 點的真實世界平面,方式是映射這個 2D 點到下方最近的平面上。result.worldTransform 是一個 4x4 矩陣,具有檢測到的平面的所有 transform 信息,而 result.worldTransform.translation 則用于只取出位置。

現在我們已經具備所需的全部信息,以便根據屏幕上的 2D 點向檢測到的平面上放置 3D 對象。所以下面開始繪制吧。

繪圖

首先解釋一下如何利用計算機視覺跟隨人的手指來繪制圖形。繪制是通過檢測手指移動的每個位置完成的,在對應的位置放置一個頂點,并將每個頂點與前面的頂點相連。頂點可以通過一條簡單的線連接,如果需要平滑的輸出的話,則可以通過 Bezier 曲線完成。

為了簡單起見,我們會使用一些原生的繪圖方法。對于手指的新位置,我們會在被檢測到的平面上放置一個非常小的圓角 box,高度幾乎為零。看起來就像一個點一樣。用戶完成繪制并點擊 3D 按鈕后,則會根據用戶手指的移動改變放置對象的高度。

下面的代碼展示了用于表示點的 PointNode 類:

let POINT_SIZE = CGFloat(0.003)

let POINT_HEIGHT = CGFloat(0.00001)

class PointNode: SCNNode {

static var boxGeo: SCNBox?

override init() {

super.init()

if PointNode.boxGeo == nil {

PointNode.boxGeo = SCNBox(width: POINT_SIZE, height: POINT_HEIGHT, length: POINT_SIZE, chamferRadius: 0.001)

// 設置點的材質

let material = PointNode.boxGeo!.firstMaterial

material?.lightingModel = SCNMaterial.LightingModel.blinn

material?.diffuse.contents? = UIImage(named: "wood-diffuse.jpg")

material?.normal.contents? = UIImage(named: "wood-normal.png")

material?.specular.contents = UIImage(named: "wood-specular.jpg")

}

let object = SCNNode(geometry: PointNode.boxGeo!)

object.transform = SCNMatrix4MakeTranslation(0.0, Float(POINT_HEIGHT) / 2.0, 0.0)

self.addChildNode(object)

}

. . .

在上面的代碼把幾何體沿 y 軸移動了高度的一半。這樣做是為了確保對象的底部總是處于 y = 0 的位置,這樣看起來就像在平面上一樣。

下面,在 SceneKit 的 renderer 回調函數中,使用 PointNode 類繪制一個指示來表示筆尖。如果開啟了繪圖的話,就會在那個位置放一個點下去,如果開啟的是 3D 模式,則會將繪圖抬高,變成 3D 結構體:

func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {

updateFocusSquare()

// 設置表示虛擬筆尖的點

if (self.virtualPenTip == nil) {

self.virtualPenTip = PointNode(color: UIColor.red)

self.sceneView.scene.rootNode.addChildNode(self.virtualPenTip!)

}

// 繪圖

if let screenCenterInWorld = worldPositionFromScreenPosition(self.screenCenter, self.sceneView) {

// 更新虛擬筆尖位置

self.virtualPenTip?.isHidden = false

self.virtualPenTip?.simdPosition = screenCenterInWorld

// 繪制新的點

if (self.inDrawMode && !self.virtualObjectManager.pointNodeExistAt(pos: screenCenterInWorld)){

let newPoint = PointNode()

self.sceneView.scene.rootNode.addChildNode(newPoint)

self.virtualObjectManager.loadVirtualObject(newPoint, to: screenCenterInWorld)

}

// 將繪圖轉為 3D

if (self.in3DMode ) {

if self.trackImageInitialOrigin != nil {

DispatchQueue.main.async {

let newH = 0.4 *? (self.trackImageInitialOrigin!.y - screenCenterInWorld.y) / self.sceneView.frame.height

self.virtualObjectManager.setNewHeight(newHeight: newH)

}

}

else {

self.trackImageInitialOrigin = screenCenterInWorld

}

}

}

檢測用戶指尖

Apple 在 iOS 11 發布的另一個牛逼閃閃的庫是 Vision 框架。它以一種相當方便和有效的方式提供可一些計算機視覺技術。我們會使用其中的對象追蹤技術。對象追蹤的工作原理如下:首先需要提供一張圖像,以及圖像中被追蹤的對象的正方形邊界坐標。然后調用幾個函數來初始化追蹤。最后,為其提供一個新的圖像以及之前操作獲得的分析結果,在新圖像里該對象的位置發生了改變。如果我們給定了這些信息,它就會返回對象的新位置。

下面采用一種巧妙的方式。讓用戶把手放在桌上,就像在握著一支筆,然后確保指甲蓋面向攝像頭,然后點擊屏幕上的指甲蓋。這里需要說明兩點。第一,指甲蓋應該具有足夠的獨特性,以便在白色指甲蓋、皮膚和桌子之間實現追蹤。也就是說深色皮膚會讓追蹤更加可靠。第二,因為用戶是把手放在桌上的,再加上我們已經檢測到了桌子的平面,所以將指甲蓋的位置從 2D 視圖映射到 3D 環境中的話,位置就會和手指在桌子上的位置極為接近。

下面這張圖顯示了 Vision 庫檢測到的特征點:

然后用一個觸摸手勢來初始化指甲蓋追蹤:

// MARK: 對象追蹤

fileprivate var lastObservation: VNDetectedObjectObservation?

var trackImageBoundingBox: CGRect?

let trackImageSize = CGFloat(20)

@objc private func tapAction(recognizer: UITapGestureRecognizer) {

lastObservation = nil

let tapLocation = recognizer.location(in: view)

// 用視圖坐標空間設置 image 中的 rect 以便用于追蹤

let trackImageBoundingBoxOrigin = CGPoint(x: tapLocation.x - trackImageSize / 2, y: tapLocation.y - trackImageSize / 2)

trackImageBoundingBox = CGRect(origin: trackImageBoundingBoxOrigin, size: CGSize(width: trackImageSize, height: trackImageSize))

let t = CGAffineTransform(scaleX: 1.0 / self.view.frame.size.width, y: 1.0 / self.view.frame.size.height)

let normalizedTrackImageBoundingBox = trackImageBoundingBox!.applying(t)

// 將 rect 從視圖坐標控件轉換為圖片空間

guard let fromViewToCameraImageTransform = self.sceneView.session.currentFrame?.displayTransform(withViewportSize: self.sceneView.frame.size, orientation: UIInterfaceOrientation.portrait).inverted() else {

return

}

var trackImageBoundingBoxInImage =? normalizedTrackImageBoundingBox.applying(fromViewToCameraImageTransform)

trackImageBoundingBoxInImage.origin.y = 1 - trackImageBoundingBoxInImage.origin.y? // Image space uses bottom left as origin while view space uses top left

lastObservation = VNDetectedObjectObservation(boundingBox: trackImageBoundingBoxInImage)

}

上面最棘手的部分就是如何把點擊位置從 UIView 坐標控件轉換到圖片坐標空間。ARKit 只為我們提供了從圖像坐標空間轉換為 viewport 坐標控件的 displayTransform 矩陣。所以如何實現相反的操作呢?只要使用逆矩陣即可。我在這篇文章里已經嘗試盡量少用數學,但在 3D 世界里有時就是難以避免。

下面。在 renderer 中提供一個新圖像來追蹤手指的新位置:

func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {

// 追蹤指甲蓋

guard let pixelBuffer = self.sceneView.session.currentFrame?.capturedImage,

let observation = self.lastObservation else {

return

}

let request = VNTrackObjectRequest(detectedObjectObservation: observation) { [unowned self] request, error in

self.handle(request, error: error)

}

request.trackingLevel = .accurate

do {

try self.handler.perform([request], on: pixelBuffer)

}

catch {

print(error)

}

. . .

}

對象追蹤完成后,會調用一個回調函數,用它來更新指甲蓋的位置。基本就是上面在觸摸手勢里相反的代碼:

fileprivate func handle(_ request: VNRequest, error: Error?) {

DispatchQueue.main.async {

guard let newObservation = request.results?.first as? VNDetectedObjectObservation else {

return

}

self.lastObservation = newObservation

var trackImageBoundingBoxInImage = newObservation.boundingBox

// 從圖像空間轉換到視圖空間

trackImageBoundingBoxInImage.origin.y = 1 - trackImageBoundingBoxInImage.origin.y

guard let fromCameraImageToViewTransform = self.sceneView.session.currentFrame?.displayTransform(withViewportSize: self.sceneView.frame.size, orientation: UIInterfaceOrientation.portrait) else {

return

}

let normalizedTrackImageBoundingBox = trackImageBoundingBoxInImage.applying(fromCameraImageToViewTransform)

let t = CGAffineTransform(scaleX: self.view.frame.size.width, y: self.view.frame.size.height)

let unnormalizedTrackImageBoundingBox = normalizedTrackImageBoundingBox.applying(t)

self.trackImageBoundingBox = unnormalizedTrackImageBoundingBox

// 獲取追蹤的圖像在圖像空間的位置在距離最近的檢測到的平面上的映射

if let trackImageOrigin = self.trackImageBoundingBox?.origin {

self.lastFingerWorldPos = self.virtualObjectManager.worldPositionFromScreenPosition(CGPoint(x: trackImageOrigin.x - 20.0, y: trackImageOrigin.y + 40.0), in: self.sceneView)

}

}

}

最后,繪圖時使用 self.lastFingerWorldPos 而不是屏幕中心,這樣就全部結束了。

談一談 ARKit 和未來

在這篇文章里,我們感受到了 AR 如何通過與用戶的手指和現實生活中的桌子交互來實現沉浸式體驗。隨著計算機視覺的發展,以及新增加的對 AR 友好的硬件(如深度攝像頭),我們可以就可以更多地獲取身邊對象的 3D 結構。

盡管微軟的 Hololens 設備還沒有向大眾發布,但微軟已經決心要贏得這場 AR 競賽,這個設備組合了 AR 定制的硬件并帶有高級 3D 環境識別技術。你可以靜靜看著誰會贏得這場比賽,也可以現在就加入開發沉浸式 AR app 的大軍!但是一定要做點對人類有意義的事,而不是把我們變成兔子。

附錄

Apple 的 ARKit 為開發者提供了哪些功能?

ARKit 可以讓開發者在 iPhone 和 iPad 上構建沉浸式增強現實 app,通過分析攝像頭視圖展示的場景并找出房間里的水平面。

如何用 Apple 的 Vision 庫來追蹤對象?

Apple 的 Vision 庫可以讓開發者追蹤視頻流中的對象。開發者提供初始圖像幀中待追蹤對象的矩形坐標,然后提供視頻幀,這個庫就會返回該對象的最新位置。

如何上手 Apple 的 ARKit?

要上手 Apple 的 ARKit,在 iPhone 6s 或更高的設備上下載 iOS 11 并用 New > Project > Augmented Reality App 創建一個新的 ARKit 項目。同時也可以看看蘋果在這里提供的 AR 示例代碼:https://developer.apple.com/arkit/

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,836評論 6 540
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,275評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,904評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,633評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,368評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,736評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,740評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,919評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,481評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,235評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,427評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,968評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,656評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,055評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,348評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,160評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,380評論 2 379

推薦閱讀更多精彩內容