写给VR手游开发小白的教程:(二)UnityVR插件CardboardSDKForUnity解析(一)

现在我们已经有了开发环境,还没安装环境的小伙伴可以看上一篇:
(一)Unity3D进行Android开发的环境搭建(虚拟机调试)

今天主要介绍的是谷歌为自己的Cardboard平台提供的开发工具Cardboard SDK插件解析。Cardboard是谷歌公司在14年发布的一款极具创意的产品,由手机内部的传感器支持,它仅需硬纸板和两片透镜就能打造移动平台上的VR体验。Cardboard这里不多做介绍,网上可以买到原装正版,价格在50以内很便宜,有条件的人可以去体验一下。

在正式开始之前,先说明一下本节需要对Unity3D引擎有一些基础才能较流畅的看完,若是中间有疑问可以评论也可私信,疑问较多也可以再补充一章专门介绍基础的东西。

上一章忘记说了,如果是VR应用的话,就不要在虚拟机里跑了(虚拟机根本没有传感器去定位你的头部,也就不存在VR一说了,不过简单的小游戏还是可以在虚拟机上跑的,所以如果是跑大型应用的话,还是选择真机吧),虚拟机的存在其实纯粹是为了学习示例用的。

没有真机的同学,这个demo在Unity里也可以跑,具体按住Alt移动鼠标就相当于转动头部,按住Ctrl移动鼠标相当于歪脖子看。

/***************************************************************分割线*******************************************************************/
首先要为Unity安装这个插件,对于本插件,目前网上有些资源是不带demo的,正好我这边有一个带demo的,附上下载地址:
http://download.csdn.net/detail/mao_xiao_feng/9577849
导入以后,工程目录下多了以下两个文件夹,双击打开DemoScene下的场景

Paste_Image.png

场景大概就是以下这个样子了,这个demo是插件中提供的官方示例,一定要把里面所有东西都研究透,才能很好理解SDK当中的每一个脚本的功能。

Paste_Image.png

直接切入主题,看到CardboardMain这个物体已经被设为了Prefab,那么它一定是获得VR效果的关键性物体,事实上所有VR的实现都在这个物体极其子物体下,我们把它一层层的剥离开来。
CardboardMain物体上只绑定了这一个脚本,开始看源码

Paste_Image.png
using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;

// The Cardboard object communicates with the head-mounted display in order to:
// - Query the device for viewing parameters
// - Retrieve the latest head tracking data
// - Provide the rendered scene to the device for distortion correction

public class Cardboard : MonoBehaviour {
// The singleton instance of the Cardboard class.
public static Cardboard SDK {
get {
if (sdk == null) {
sdk = UnityEngine.Object.FindObjectOfType<Cardboard>();
}
if (sdk == null) {
Debug.Log("Creating Cardboard object");
var go = new GameObject("Cardboard");
sdk = go.AddComponent<Cardboard>();
go.transform.localPosition = Vector3.zero;
}
return sdk;
}
}
private static Cardboard sdk = null;

public bool DistortionCorrection {
get {
return distortionCorrection;
}
set {
if (value != distortionCorrection && device != null) {
device.SetDistortionCorrectionEnabled(value && NativeDistortionCorrectionSupported);
}
distortionCorrection = value;
}
}
 [SerializeField]
private bool distortionCorrection = true;

public bool VRModeEnabled {
get {
return vrModeEnabled;
}
set {
if (value != vrModeEnabled && device != null) {
device.SetVRModeEnabled(value);
}
vrModeEnabled = value;
}
}
 [SerializeField]
private bool vrModeEnabled = true;

public bool EnableAlignmentMarker {
get {
return enableAlignmentMarker;
}
set {
if (value != enableAlignmentMarker && device != null) {
device.SetAlignmentMarkerEnabled(value && NativeUILayerSupported);
}
enableAlignmentMarker = value;
}
}
 [SerializeField]
private bool enableAlignmentMarker = true;

public bool EnableSettingsButton {
get {
return enableSettingsButton;
}
set {
if (value != enableSettingsButton && device != null) {
device.SetSettingsButtonEnabled(value && NativeUILayerSupported);
}
enableSettingsButton = value;
}
}
 [SerializeField]
private bool enableSettingsButton = true;

public bool TapIsTrigger = true;

public float NeckModelScale {
get {
return neckModelScale;
}
set {
value = Mathf.Clamp01(value);
if (!Mathf.Approximately(value, neckModelScale) && device != null) {
device.SetNeckModelScale(value);
}
neckModelScale = value;
}
}
 [SerializeField]
private float neckModelScale = 0.0f;

public bool AutoDriftCorrection {
get {
return autoDriftCorrection;
}
set {
if (value != autoDriftCorrection && device != null) {
device.SetAutoDriftCorrectionEnabled(value);
}
autoDriftCorrection = value;
}
}
 [SerializeField]
private bool autoDriftCorrection = true;

#if UNITY_IOS
public bool SyncWithCardboardApp {
get {
return syncWithCardboardApp;
}
set {
if (value && value != syncWithCardboardApp) {
Debug.LogWarning("Remember to enable iCloud capability in Xcode, "
+ "and set the 'iCloud Documents' checkbox. "
+ "Not doing this may cause the app to crash if the user tries to sync.");
}
syncWithCardboardApp = value;
}
}
 [SerializeField]
private bool syncWithCardboardApp = false;
#endif

#if UNITY_EDITOR
// Mock settings for in-editor emulation of Cardboard while playing.
public bool autoUntiltHead = true;

// Whether to perform distortion correction in the editor.
public bool simulateDistortionCorrection = true;

// Use unity remote as the input source.
 [HideInInspector]
public bool UseUnityRemoteInput = false;

public CardboardProfile.ScreenSizes ScreenSize {
get {
return screenSize;
}
set {
if (value != screenSize) {
screenSize = value;
device.UpdateScreenData();
}
}
}
 [SerializeField]
private CardboardProfile.ScreenSizes screenSize = CardboardProfile.ScreenSizes.Nexus5;

public CardboardProfile.DeviceTypes DeviceType {
get {
return deviceType;
}
set {
if (value != deviceType) {
deviceType = value;
device.UpdateScreenData();
}
}
}
 [SerializeField]
public CardboardProfile.DeviceTypes deviceType = CardboardProfile.DeviceTypes.CardboardJun2014;
#endif

// The VR device that will be providing input data.
private static BaseVRDevice device;

public bool NativeDistortionCorrectionSupported { get; private set; }

public bool NativeUILayerSupported { get; private set; }

// The texture that Unity renders the scene to. This is sent to the VR device,
// which renders it to screen, correcting for lens distortion.
public RenderTexture StereoScreen {
get {
// Don't need it except for distortion correction.
if (!distortionCorrection || !vrModeEnabled) {
return null;
}
if (stereoScreen == null && NativeDistortionCorrectionSupported) {
StereoScreen = CreateStereoScreen(); // Note: use set{}
}
return stereoScreen;
}
set {
if (value == stereoScreen) {
return;
}
if (!NativeDistortionCorrectionSupported && value != null) {
Debug.LogError("Can't set StereoScreen: native distortion correction is not supported.");
return;
}
if (stereoScreen != null) {
stereoScreen.Release();
}
stereoScreen = value;
if (stereoScreen != null && !stereoScreen.IsCreated()) {
stereoScreen.Create();
}
if (device != null) {
device.SetStereoScreen(stereoScreen);
}
}
}
private static RenderTexture stereoScreen = null;

public bool UseDistortionEffect {
get {
return !NativeDistortionCorrectionSupported && distortionCorrection && vrModeEnabled
&& SystemInfo.supportsRenderTextures;
}
}

// Describes the current device, including phone screen.
public CardboardProfile Profile {
get {
return device.Profile;
}
}

// Distinguish the stereo eyes.
public enum Eye {
Left,
Right,
Center
}

// When asking for project, viewport, etc, whether to assume viewing through
// the lenses.
public enum Distortion {
Distorted, // Viewing through the lenses
Undistorted // No lenses
}

// The transformation of head from origin in the tracking system.
public Pose3D HeadPose {
get {
return device.GetHeadPose();
}
}

// The transformation from head to eye.
public Pose3D EyePose(Eye eye) {
return device.GetEyePose(eye);
}

// The projection matrix for a given eye.
public Matrix4x4 Projection(Eye eye, Distortion distortion = Distortion.Distorted) {
return device.GetProjection(eye, distortion);
}

// The screen-space rectangle each eye should render into.
public Rect Viewport(Eye eye, Distortion distortion = Distortion.Distorted) {
return device.GetViewport(eye, distortion);
}

// The distance range from the viewer in user-space meters where objects
// may be viewed comfortably in stereo. If the center of interest falls
// outside this range, the stereo eye separation should be adjusted to
// keep the onscreen disparity within the limits set by this range.
public Vector2 ComfortableViewingRange {
get {
return defaultComfortableViewingRange;
}
}
private readonly Vector2 defaultComfortableViewingRange = new Vector2(1.0f, 100000.0f);

private void InitDevice() {
if (device != null) {
device.Destroy();
}
device = BaseVRDevice.GetDevice();
device.Init();

List<string> diagnostics = new List<string>();
NativeDistortionCorrectionSupported = device.SupportsNativeDistortionCorrection(diagnostics);
if (diagnostics.Count > 0) {
Debug.LogWarning("Built-in distortion correction disabled. Causes: ["
+ String.Join("; ", diagnostics.ToArray()) + "]");
}
diagnostics.Clear();
NativeUILayerSupported = device.SupportsNativeUILayer(diagnostics);
if (diagnostics.Count > 0) {
Debug.LogWarning("Built-in UI layer disabled. Causes: ["
+ String.Join("; ", diagnostics.ToArray()) + "]");
}

device.SetVRModeEnabled(vrModeEnabled);
device.SetDistortionCorrectionEnabled(distortionCorrection
&& NativeDistortionCorrectionSupported);
device.SetAlignmentMarkerEnabled(enableAlignmentMarker
&& NativeUILayerSupported);
device.SetSettingsButtonEnabled(enableSettingsButton
&& NativeUILayerSupported);
device.SetNeckModelScale(neckModelScale);
device.SetAutoDriftCorrectionEnabled(autoDriftCorrection);

device.UpdateScreenData();
}

// NOTE: Each scene load causes an OnDestroy of the current SDK, followed
// by and Awake of a new one. That should not cause the underlying native
// code to hiccup. Exception: developer may call Application.DontDestroyOnLoad
// on the SDK if they want it to survive across scene loads.
void Awake() {
if (sdk == null) {
sdk = this;
}
if (sdk != this) {
Debug.LogWarning("Cardboard SDK object should be a singleton.");
enabled = false;
return;
}
#if UNITY_IOS
Application.targetFrameRate = 60;
#endif
InitDevice();
AddDummyCamera();
StereoScreen = null;
}

public event Action OnTrigger;

public event Action OnTilt;

public bool Triggered { get; private set; }

public bool Tilted { get; private set; }

private bool updated = false;

private CardboardUILayer uiLayer = null;

public void UpdateState() {
if (!updated) {
device.UpdateState();
if (TapIsTrigger) {
if (Input.GetMouseButtonUp(0)) {
device.triggered = true;
}
if (Input.GetKeyUp(KeyCode.Escape)) {
device.tilted = true;
}
}
updated = true;
}
}

private void DispatchEvents() {
Triggered = device.triggered;
Tilted = device.tilted;
device.triggered = false;
device.tilted = false;

if (Tilted) {
if (OnTilt != null) {
OnTilt();
}
}
if (Triggered) {
if (OnTrigger != null) {
OnTrigger();
}
}
}

private void AddDummyCamera() {
var go = gameObject;
if (go.GetComponent<Camera>()) {
go = new GameObject("CardboardDummy");
go.transform.parent = gameObject.transform;
}
var cam = go.AddComponent<Camera>();
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = Color.black;
cam.cullingMask = 0;
cam.useOcclusionCulling = false;
cam.depth = -100;
}

IEnumerator EndOfFrame() {
while (true) {
yield return new WaitForEndOfFrame();
UpdateState();
device.PostRender(vrModeEnabled);
if (vrModeEnabled && !NativeUILayerSupported) {
if (uiLayer == null) {
uiLayer = new CardboardUILayer();
}
uiLayer.Draw();
}
DispatchEvents();
updated = false;
}
}

// Return a StereoScreen with sensible default values.
public RenderTexture CreateStereoScreen() {
return device.CreateStereoScreen();
}

// Reset the tracker so that the user's current direction becomes forward.
public void Recenter() {
device.Recenter();
}

// Set the screen coordinates of the mouse/touch event.
public void SetTouchCoordinates(int x, int y) {
device.SetTouchCoordinates(x, y);
}

void OnEnable() {
device.OnPause(false);
StartCoroutine("EndOfFrame");
}

void OnDisable() {
StopCoroutine("EndOfFrame");
device.OnPause(true);
}

void OnApplicationPause(bool pause) {
device.OnPause(pause);
}

void OnApplicationFocus(bool focus) {
device.OnFocus(focus);
}

void OnLevelWasLoaded(int level) {
device.Reset();
}

void OnDestroy() {
if (device != null) {
device.Destroy();
}
if (sdk == this) {
sdk = null;
}
}

void OnApplicationQuit() {
device.OnApplicationQuit();
}

//********* OBSOLETE ACCESSORS *********

 [System.Obsolete("Use DistortionCorrection instead.")]
public bool nativeDistortionCorrection {
get { return DistortionCorrection; }
set { DistortionCorrection = value; }
}

 [System.Obsolete("InCardboard is deprecated.")]
public bool InCardboard { get { return true; } }

 [System.Obsolete("Use Triggered instead.")]
public bool CardboardTriggered { get { return Triggered; } }

 [System.Obsolete("Use HeadPose instead.")]
public Matrix4x4 HeadView { get { return HeadPose.Matrix; } }

 [System.Obsolete("Use HeadPose instead.")]
public Quaternion HeadRotation { get { return HeadPose.Orientation; } }

 [System.Obsolete("Use HeadPose instead.")]
public Vector3 HeadPosition { get { return HeadPose.Position; } }

 [System.Obsolete("Use EyePose() instead.")]
public Matrix4x4 EyeView(Eye eye) {
return EyePose(eye).Matrix;
}

 [System.Obsolete("Use EyePose() instead.")]
public Vector3 EyeOffset(Eye eye) {
return EyePose(eye).Position;
}

 [System.Obsolete("Use Projection() instead.")]
public Matrix4x4 UndistortedProjection(Eye eye) {
return Projection(eye, Distortion.Undistorted);
}

 [System.Obsolete("Use Viewport() instead.")]
public Rect EyeRect(Eye eye) {
return Viewport(eye, Distortion.Distorted);
}

 [System.Obsolete("Use ComfortableViewingRange instead.")]
public float MinimumComfortDistance { get { return ComfortableViewingRange.x; } }

 [System.Obsolete("Use ComfortableViewingRange instead.")]
public float MaximumComfortDistance { get { return ComfortableViewingRange.y; } }
}

来自CODE的代码片Cardboard.cs

上面的源代码理解起来比较麻烦,我解析的时候会把变量定义等一些顺序调换一下注释部分
The Cardboard object communicates with the head-mounted display in order to:Query the device for viewing parameters Retrieve the latest head tracking data Provide the rendered scene to the device for distortion correction 翻译/Cardboard物体与头盔显示器进行交互以获得设备上的一些参数,返回最近头部跟踪数据,为被渲染的场景提供失真校正/
定义了一个Cardboard类型的静态公有变量SDK,主要用来获取游戏中的Cardboard.cs脚本,可以看到它是只读的,即SDK提供了一个共有的访问接口,在任何时候它都只有一个实例。 // The singleton instance of the Cardboard class. 翻译/Cardboard类的单件实例/---关于单件模式,编程用的还是挺多的。
BaseVRDevice类型的私有静态变量device,BaseVRDevice也是插件当中定义的一个类,注意!!它与Cardboard类不同,它不是脚本类!!这个变量用的比较多 // The VR device that will be providing input data. 翻译/VR设备(在这里直接理解为手机吧!)提供输入的数据/
公有bool类型的变量DistortionCorrection,默认为true。DistortionCorrection我把它翻译为失真校正(暂且这样翻译吧),读属性不说了,写属性加了个if语句,想说明假如设备接入了并且支持失真校正功能,才能修改它的值,不然一直为true。(这个变量是控制扭曲的,开或者关的效果如下,差距应该一目了然)


[图片上传中。。。(5)]
同上还有VRModeEnabled(VR模式的使能,开了就是分屏),EnableAlignmentMarker(就是下图黄圈中间那根线,关了,线就没了),EnableSettingsButton(就是下图绿圈内的设置按钮,同样也是关了就没了),AutoDriftCorrection(这个属性还没搞懂先放着),NeckModelScale(这个是专业术语,应该是颈部的微调吧...猜的,值在0-1之间,调整的话视角会有微小的变化,但可以忽略不计)
[图片上传中。。。(6)]
后面从#if UNITY_IOS到#endif中间的部分是预编译部分,根据iOS,unity_editor,Android选择性的编译,可以根据自己的平台选择性的看。我们现在使用unity编辑器在运行,所以他编译的是#if UNITY_EDITOR到#endif中间的这部分。
RenderTexture类型的公有变量StereoScreen,渲染纹理是一种即时更新的纹理。//The texture that Unity renders the scene to. This is sent to the VR device,which renders it to screen, correcting for lens distortion. 翻译/Unity渲染场景产生的纹理,它被传输到VR设备上,在屏幕上被绘制产生正确的透镜弯曲效果/这个变量的读属性当vr模式或者distortionCorrection关闭时,为null,写属性也只有在支持distortionCorrection的时候可以使用,总的来说,可以把它看作产生弯曲效果的一个工具或者一个中间量。
bool类型只读属性的UseDistortionEffect,这个量是用来判断透镜效果是否开启的量,后面会用到。
CardboardProfile类型的公有变量Profile,也是只读的。 // Describes the current device, including phone screen. 翻译/描述当前设备,包括手机屏幕/
公有枚举类型Eye。 // Distinguish the stereo eyes. 翻译/看立体效果时用来区分左右眼/
公有枚举类型Distortion。这个量主要控制在某些特殊情况下是否需要透镜预览,在下面会用到
Pose3D类型的公有量HeadPose。 // The transformation of head from origin in the tracking system. 翻译/在跟踪在系统中,头部距离起始点的信息/由于这里Pose3D也是插件自定义的类,后续需要分析Pose3D这个类才能知道信息是如何传递的。同理还有变量EyePose。
返回Matrix4x4类型的方法Projection(Eye eye, Distortion distortion = Distortion.Distorted)返回结果是device.GetProjection(eye, distortion);从两个参数推断这个方法是要根据眼睛计算返回一个投影矩阵,在Unity圣典中阐述过Matrix4x4类型的投影矩阵,关于投影矩阵,涉及到计算机图形学的东西,我目前也正在学习,给一个链接大家可以学习一下。
http://blog.csdn.net/yanwei2016/article/details/7326180
Viewport(Eye eye, Distortion distortion = Distortion.Distorted)方法返回视口矩形,应该是处理在屏幕上的位置的一个方法
Vector2类型的变量ComfortableViewingRange,只读,控制我们去看空间中物体的一个最舒服的距离范围,这个量不需要去配置,理解就行。
私有方法InitDevice()用于初始化设备,因为涉及到BaseVRDevice类,我们以后再去解析其细节。
私有方法AddDummyCamera()用来添加一个黑色背景,当然背景也可以自己更换。
一些琐碎的定义先到这里

/********************************************************分割线********************************************************************/
NOTE: Each scene load causes an OnDestroy of the current SDK, followed by and Awake of a new one. That should not cause the underlying native code to hiccup. Exception: developer may call Application.DontDestroyOnLoad on the SDK if they want it to survive across scene loads.
翻译/说明:每一个新场景被加载会导致现在sdk的destroy,然后产生新的sdk,这会发生一些问题(这句话实在不会翻译了),所以开发者们在切换场景的时候如果希望sdk依然存活,可以使用Application.DontDestroyOnLoad/。这里其实可以间接的说明SDK是采用的单件模式,因为我们无法用new()去创建,而且自始而终最多只有一个实例。
比较关键的awake函数来了

  void Awake() {  
  if (sdk == null) {   
   sdk = this;    }  
  if (sdk != this) { 
     Debug.LogWarning("Cardboard SDK object should be a singleton.");      enabled = false;      return;  
  }
#if UNITY_IOS   
 Application.targetFrameRate = 60;
#endif  
  InitDevice();  
  AddDummyCamera();    
StereoScreen = null;  }

相信上面的语句根据我之前的解释大家都能看懂了
这个类中没有Update()所以我认为更新可能放在BaseVRDevice类型的device里面了。
我先码到这里(太累了),后面接着此篇,另外会解析BaseVRDevice这个类(很重要!!),还有CardboardProfile这个类。
总结:这一章还只是把每个类的功能和大致框架整理了一下,并没有涉及到很多细节的东西,也没有涉及到原理,注意我们现在还在顶端的父物体Cardboard物体上分析,后面当涉及到原理的的时候,就要开始看下层的Head,和它的子物体几个camera了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容