three.js 笔记七 Matrix

一、行主序、列主序

概念参考行主序 列主序

以线性代数中描述的矩阵为标准,行主序就是依次按行存储,而列主序就是依次按列存储。在threeJS中:

var A = new THREE.Matrix4();
A.set(1, 2, 3, 4,
    5, 6, 7, 8,
    9, 10, 11, 12,
    13, 14, 15, 16);
console.log(A);

var B = new THREE.Matrix4();
B.set(16, 15, 14, 13,
    12, 11, 10, 9,
    8, 7, 6, 5,
    4, 3, 2, 1);
console.log(B);

var C = new THREE.Matrix4();
C.multiplyMatrices (A, B);    
console.log(C);

其运行结果为:

image.png

在网上找一个在线矩阵计算器,比如http://www.yunsuan.info/matrixcomputations/solvematrixmultiplication.html
相对应的计算结果如下:
image.png

因此可以认为,threejs矩阵内部储存形式为列主序,表达和描述的仍然是线性代数中行主序,set()函数就是以行主序接受矩阵参数的。

二、如何根据变换设计自己的矩阵

概念性的东西,可以参考
线性代数笔记三 线性变换和矩阵乘法
图形学笔记一 仿射变换和齐次坐标

1.向量或点的缩放平移等操作

这部分比较好处理,例子可以参考
three.js 之 Matrix

2.坐标系的转化

冯乐乐讲MVP的例子也很好,可以参考
UnityShader精要笔记二 数学基础

核心思路就是以世界坐标为中转,应用坐标的变换等价于基变换。

比如模型坐标系转世界坐标系,就是模型空间任意一点计算出其在世界坐标系的位置。即模型每个点动了,整个模型也动了。

而世界坐标系转观察坐标系,则是先用观察坐标系转世界坐标系之后求逆,这样快速运算。所以说,世界坐标系是用来中转的,世界中心点不会动。

三、THREEJS封装的矩阵API
1.平移
var vector = new THREE.Vector3(20, 20, 0);
var matrix = new THREE.Matrix4();
matrix.makeTranslation(10, 40, 0);
vector.applyMatrix4(matrix);
2.旋转
matrix.makeRotationX(angle);
matrix.makeRotationY(angle);
matrix.makeRotationZ(angle);
matrix.makeRotationAxis(axis, angle);
matrix.makeRotationFromEuler(euler);
matrix.makeRotationFromQuaternion(quaternion);

前三个方法分别代表的是绕X、Y、Z三个轴旋转,无需赘述。
第四个方法是前三个方法的整合版,第一个参数表示的是代表xyz的THREE.Vector3,第二个参数是旋转的弧度。下面两行代码是等价的:

matrix.makeRotationX(Math.PI);
matrix.makeRotationAxis(new THREE.Vector3(1, 0, 0), Math.PI);

第五个方法表示围绕x、y和z轴的旋转,这是表示旋转最常用的方式;第六个方法是一种基于轴和角度表示旋转的替代方法。

关于旋转,可以参考
Cocos 3.x 四元数 rotateAroundLocal
Three.js欧拉对象Euler和四元数Quaternion

构造函数:Euler(x,y,z,order)
参数xyz分别表示绕xyz轴旋转的角度值,角度单位是弧度。参数order表示旋转顺序,默认值XYZ,也可以设置为YXZ、YZX等值

// 创建一个欧拉对象,表示绕着xyz轴分别旋转45度,0度,90度
var Euler = new THREE.Euler( Math.PI/4,0, Math.PI/2);

四元数的方法.setFromAxisAngle(axis, angle)通过旋转轴axis和旋转角度angle设置四元数数据,也就是x、y、z和w四个分量。

var quaternion = new THREE.Quaternion();
// 旋转轴new THREE.Vector3(0,1,0)
// 旋转角度Math.PI/2
quaternion.setFromAxisAngle(new THREE.Vector3(0,1,0),Math.PI/2)
console.log('查看四元数结构',quaternion);

四元数乘法.multiply()
对象的一个旋转可以用一个四元数表示,两次连续旋转可以理解为两次旋转对应的四元数对象进行乘法运算。

// 四元数q1、q2分别表示一个旋转,两个四元数进行乘法运算,相乘结果保存在q2中
// 在q1表示的旋转基础在进行q2表示的旋转操作
q1.quaternion.multiply( q2 );

欧拉、四元数和矩阵转化
欧拉对象、四元数对象和旋转矩阵可以相关转化,都可以表示旋转变换。

//通过矩阵对象Matrix4的.makeRotationFromQuaternion(q)方法可以把四元数转化对应的矩阵对象。
Matrix4.makeRotationFromQuaternion(q)

//通过欧拉对象设置四元数对象
quaternion.setFromEuler(Euler)

//四元数转化为欧拉对象
Euler.setFromQuaternion(quaternion)

Object3D对象角度属性.rotation的值是欧拉对象Euler,四元数属性.quaternion的值是四元数对象Quaternion。

执行Object3D对象旋转方法,会同时改变对象的角度属性和四元数属性。四元数属性和位置.position、缩放属性.scale一样会转化为对象的本地矩阵属性.matrix,本地矩阵属性值包含了旋转矩阵、缩放矩阵、平移矩阵。

Object3D对象角度属性.rotation和四元数属性.quaternion是相互关联的一个改变会同时改变另一个。

// 一个网格模型对象,基类是Object3D
var mesh = new THREE.Mesh()
// 绕z轴旋转
mesh.rotateZ(Math.PI)

console.log('查看角度属性rotation',mesh.rotation);
console.log('查看四元数属性quaternion',mesh.quaternion);
3.compose

参考Three.js 克隆其他模型的矩阵 Matrix4

image.png

//使用make系列的方法操作
Object3D.applyMatrix(new THREE.Matrix4().makeScale(2,1,1));
Object3D.applyMatrix(new THREE.Matrix4().makeTranslation(0,4,0));
Object3D.applyMatrix(new THREE.Matrix4().makeRotationZ(Math.PI/6));
//使用compose方法操作
var matrix = new THREE.Matrix4();
var trans = new THREE.Vector3(0,4,0);
var rotat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0,0,Math.PI/6));
var scale = new THREE.Vector3(2,1,1);
Object3D.applyMatrix4(matrix.compose(trans, rotat, scale)); //效果同上
image.png

就是compose的逆过程。随便举个例子。

var matrix = new THREE.Matrix4().set(1,2,3,4,2,3,4,5,3,4,5,6,4,5,6,7);
var trans = new THREE.Vector3();
var rotat = new THREE.Quaternion();
var scale = new THREE.Vector3();
matrix.decompose(trans, rotat, scale);

//返回Vector3 {x: 4, y: 5, z: 6} 因为是随便写的,所以只有平移变量不需计算就可以看出来的
console.log(trans); 

//返回Quaternion {_x: 0.05565363763555474, _y: -0.11863820054057297
//, _z: 0.051265314875937947, _w: 0.7955271896092125}
console.log(rotat); 

//返回Vector3 {x: 3.7416573867739413, y: 5.385164807134504, z: 7.0710678118654755}
console.log(scale); 

如何通过矩阵设置Object3D对象位置呢,参考108 THREE.JS 使用矩阵对3D对象进行位置设置

//最后先将模型移动到中心位置
var inverseM = new THREE.Matrix4();
inverseM.getInverse(centerM);
matrix.multiply(inverseM);

//将矩阵赋值给模型
cube.matrix = matrix;

//使用矩阵更新模型的信息
cube.matrix.decompose(cube.position, cube.quaternion, cube.scale);
4.相乘

之前用过的matrix.multiplyMatrices(matrixA, matrixB),表示 将矩阵设置为matrixA * matrixB的结果。

threejs矩阵还有前乘和后乘的区别,也很容易混淆。

在threeJS中矩阵的后乘方法为multiply():

var A = new THREE.Matrix4();
A.set(1, 2, 3, 4,
    5, 6, 7, 8,
    9, 10, 11, 12,
    13, 14, 15, 16);

var B = new THREE.Matrix4();
B.set(16, 15, 14, 13,
    12, 11, 10, 9,
    8, 7, 6, 5,
    4, 3, 2, 1);

A.multiply(B);
console.log(A);
console.log(B);

其运行结果为:


image.png

表明A.multiply(B)不会改变B的值,会改变A的值,相当于A=A*B,即后乘方法multiply()的结果就是把传入的参数放自己后面去乘。
反过来,使用前乘方法A.premultiply(B);,结果就是B∗A

5.逆矩阵
let m4 = new THREE.Matrix4();
m4.elements = [10, 8, 3, 0, 15, 7, 2, 0, 10, 6, 1, 0, 0, 0, 0, 1];
        
let m3 = new THREE.Matrix4();
//执行这行,会将m4的逆矩阵设置给m3
m3.getInverse(m4);
6.例子

image.png

参考Three.js中的矩阵,做出如图的旋转效果:

var box_geometry = new THREE.BoxGeometry();
var sphere_geometry = new THREE.SphereGeometry(0.5, 32, 32);
var cylinder_geometry = new THREE.CylinderGeometry(0.1, 0.1, 0.5);

var material = new THREE.MeshLambertMaterial({color: new THREE.Color(0.9, 0.55, 0.4)});

var box = new THREE.Mesh(box_geometry, material);
var sphere = new THREE.Mesh(sphere_geometry, material);
var cylinder = new THREE.Mesh(cylinder_geometry, material);

scene.add(box);
scene.add(sphere);
scene.add(cylinder);

box.matrixAutoUpdate = false;
sphere.matrixAutoUpdate = false;
cylinder.matrixAutoUpdate = false;

var sphere_matrix = new THREE.Matrix4().makeTranslation(0.0, 1.0, 0.0); 
sphere_matrix.multiply(new THREE.Matrix4().makeRotationZ(-Math.PI * 0.25));
sphere.applyMatrix(sphere_matrix);

var cylinder_matrix = sphere_matrix.clone(); 
cylinder_matrix.multiply(new THREE.Matrix4().makeTranslation(0.0, 0.75, 0.0)); 

cylinder.applyMatrix(cylinder_matrix);


注意这个例子中只给了部分代码,由于使用的是MeshLambertMaterial,需要添加光照才能看到几何体,当然也可以换其它material:

// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 1); // 创建环境光
this.scene.add(ambientLight); // 将环境光添加到场景

如果因为threejs版本问题,applyMatrix报undefined,改成applyMatrix4即可。

也可以参考这个例子,确认一下multiply的用法,上面有提到结论:

表明A.multiply(B)不会改变B的值,会改变A的值,相当于A=A*B

var sphere_matrix = new THREE.Matrix4().makeTranslation(0.0, 1.0, 0.0); 
sphere.applyMatrix(sphere_matrix);
var cylinder_matrix = sphere_matrix.clone(); 
cylinder_matrix.multiply(new THREE.Matrix4().makeTranslation(0.0, 0.75, 0.0)); 
cylinder.applyMatrix(cylinder_matrix);

这段代码和之前的功能是一样的:

var box = new THREE.Mesh(box_geometry, material);
var sphere = new THREE.Mesh(sphere_geometry, material);
sphere.position.y += 1;
var cylinder = new THREE.Mesh(cylinder_geometry, material);
cylinder.position.y += 1.75;
scene.add(box);
scene.add(sphere);
scene.add(cylinder);

可以看出,cylinder_matrix使用multiply,在之前的平移矩阵上,再移动了0.75

但是,如果矩阵中还有其他值,使用乘法,都会改变:

        let ttt = new THREE.Matrix4().set(1,2,3,4,2,3,4,5,3,4,5,6,4,5,6,7);
        ttt.multiply(new THREE.Matrix4().makeTranslation(10,20,30));
        console.log("ttt:",ttt.elements);

        let ttt2 = new THREE.Matrix4().set(1,2,3,4,2,3,4,5,3,4,5,6,4,5,6,7);
        ttt2.premultiply(new THREE.Matrix4().makeTranslation(10,20,30));
        console.log("ttt2:",ttt2.elements);

结果:

ttt: (16) [1, 2, 3, 4, 2, 3, 4, 5, 3, 4, 5, 6, 144, 205, 266, 327]
ttt2: (16) [41, 82, 123, 4, 52, 103, 154, 5, 63, 124, 185, 6, 74, 145, 216, 7]
spher: (16) [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1] cylinder: (16) [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.75, 0, 1]
7.Object3D.matrix和matrixWorld

仍然以UnityShader精要笔记二 数学基础中MVP的例子:

就是有个奶牛叫妞妞,她有自己的坐标空间即模型空间,在这个空间里,她的鼻子坐标是(0,2,4),最后如何显示在屏幕上呢?首先,转化为齐次坐标(0,2,4,1)。顶点变换的第一步就是将顶点坐标从模型空间变换到世界空间,这个变换通常叫做模型变换(model transform)。根据Transform的信息,妞妞进行了(2,2,2)的缩放,(0,150,0)的旋转以及(5,0,25)的平移。根据之前的知识,要先缩放再旋转再平移:

image.png

我们使用代码来验证一下:

var geometry = new THREE.BoxGeometry(1, 1, 1);
var material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
var cube = new THREE.Mesh(geometry, material);
cube.scale.set(2, 2, 2);
cube.rotateY(150 * Math.PI / 180);
cube.position.set(5, 0, 25);
var vec = new THREE.Vector4(0, 2, 4, 1);
vec.applyMatrix4(cube.matrix);
console.log("vec:", vec);

现在打印出来的是0,2,4,1 这是因为matrix并没有立即生效,可以手动调用cube.updateMatrix(),关于更新的问题后面再说,现在先换一个打印方式:

function animate() {
    requestAnimationFrame(animate);
    // cube.rotation.x += 0.01;
    // cube.rotation.y += 0.01;
    renderer.render(scene, camera);
    var vec = new THREE.Vector4(0, 2, 4, 1);
    vec.applyMatrix4(cube.matrix);
    console.log("vec:", vec);
}
//animate();

打印结果与例子中计算结果一致:vec: Vector4 {x: 9, y: 4, z: 18.07179676972449, w: 1}

这也说明,cube.matrix是由模型坐标系转向其父容器世界坐标系的。现在继续做测试,把cube再添加一个父容器:

var geometry = new THREE.BoxGeometry(1, 1, 1);
var material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
var cube = new THREE.Mesh(geometry, material);
cube.scale.set(2, 2, 2);
cube.rotateY(150 * Math.PI / 180);
cube.position.set(5, 0, 25);
var vec = new THREE.Vector4(0, 2, 4, 1);
vec.applyMatrix4(cube.matrix);

var cubeParent = new THREE.Object3D();
cubeParent.position.set(3, 0, 0);
cubeParent.add(cube);
scene.add(cubeParent);

然后打印的地方,把cube.matrix和cube.matrixWorld都打印:

function animate() {
    requestAnimationFrame(animate);
    // cube.rotation.x += 0.01;
    // cube.rotation.y += 0.01;
    renderer.render(scene, camera);
    var vec = new THREE.Vector4(0, 2, 4, 1);
    vec.applyMatrix4(cube.matrix);
    console.log("vec:", vec);
    console.log("matrix:", cube.matrix);
    console.log("matrixWorld:", cube.matrixWorld);
}
image.png

image.png

显然,能看出cube.matrixWorld是把嵌套的父容器也考虑进去,一步到位,直接转到世界坐标系。

cube.modelViewMatrix
表示对象相对于相机坐标系的变换。也就是matrixWorld左乘相机的matrixWorldInverse。
但是,我打印一下,发现这个值不对:

image.png

那没办法,我们自己用矩阵乘法计算:

camera.rotateX(30 * Math.PI / 180);
camera.position.set(0, 10, -10);

function animate() {
    requestAnimationFrame(animate);
    // cube.rotation.x += 0.01;
    // cube.rotation.y += 0.01;
    renderer.render(scene, camera);
    var vec = new THREE.Vector4(0, 2, 4, 1);
    vec.applyMatrix4(cube.matrix);
    console.log("vec:", vec);
    console.log("matrix:", cube.matrix);
    console.log("matrixWorld:", cube.matrixWorld);

    var vec2 = new THREE.Vector4(0, 2, 4, 1);
    let m = cube.matrixWorld.clone();
    m.premultiply(camera.matrixWorldInverse);
    vec2.applyMatrix4(m);
    console.log("vec2:", vec2);

回到我们的农场游戏。现在我们需要把妞妞的鼻子从世界空间变换到观察空间中。为此我们需要知道世界坐标系下摄像机的变换信息。这同样可以通过摄像机面板中的Transform组件得到:(1,1,1)的缩放,(30,0,0)的旋转,(0,10,-10)的平移。

image.png

image.png

可以看到结果与例子中的Z值是相反的,这是因为unity用的左手坐标系,而threejs是右手坐标系。

注,这里用的是premultiply,可以参考之前的结论:

表明A.multiply(B)不会改变B的值,会改变A的值,相当于A=A*B

8.camera相关的matrix

摄像机Cameras 有两个额外的四维矩阵:

  • Camera.matrixWorldInverse: 视图矩阵 - 摄像机世界坐标变换的逆矩阵。
  • Camera.projectionMatrix: 投影矩阵 - 表示将场景中的信息投影到裁剪空间。
9.makeScale会清理其它数据
let ttt = new THREE.Matrix4().set(1,2,3,4,2,3,4,5,3,4,5,6,4,5,6,7);
ttt.makeScale(2,2,2);
console.log("ttt:",ttt.elements);

let ttt2 = new THREE.Matrix4().set(1,2,3,4,2,3,4,5,3,4,5,6,4,5,6,7);
ttt2.makeTranslation(1,2,3);
console.log("ttt2:",ttt2.elements);

let ttt3 = new THREE.Matrix4().set(1,2,3,4,2,3,4,5,3,4,5,6,4,5,6,7);
ttt3.compose(new THREE.Vector3(2,2,2),new THREE.Quaternion(),new THREE.Vector3(1,2,3));
console.log("ttt3:",ttt3.elements);

结果如下:

ttt: (16) [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]

ttt2: (16) [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1]

ttt3: (16) [1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 3, 0, 2, 2, 2, 1]

也就是说,如果不想清理之前的数据,可以使用compose进行一次性转换。

10.更多的API

参考
three.js 数学方法之Matrix4

四、THREEJS来更新对象的变换

参考
three.js 之 Matrix
学习ThreeJS 04 更新机制

1.更改对象的位置,四元数,和伸缩属性,three.js 会根据这些属性重新计算对象的矩阵:
object.position.copy(start_position);
object.quaternion.copy(quaternion);

默认情况下,matrixAutoUpdate 属性是设置为 true 的,矩阵会自动重新计算(如果它们已添加到场景中,或者是已添加到场景中的另一个对象的子节点)。

var object1 = new THREE.Object3D();
var object2 = new THREE.Object3D();
object1.add( object2 );

//object1 和 object2 会自动更新它们的矩阵
scene.add( object1 ); 

如果对象是静态的,或者你希望自己手动控制什么时候重新计算,可以通过将属性设置为 false 来获取更好的性能。

object.matrixAutoUpdate = false

同时在改变任何属性之后,手动更新矩阵:

object.updateMatrix();
2.直接修改对象的矩阵
object.matrix.setRotationFromQuaternion(quaternion);
object.matrix.setPosition(start_position);
object.matrixAutoUpdate = false;

注意在这种情况下 matrixAutoUpdate 必须设置成 false。并且你要确定不要调用 updateMatrix 方法。调用 updateMatrix 会阻断对矩阵的手动更改,会根据位置、伸缩等属性重新计算矩阵。

3.matrixWorldNeedsUpdate

参考https://sogrey.top/Three.js-start/cores/#Object3D
matrixWorldNeedsUpdate : Boolean
当这个属性设置了之后,它将计算在那一帧中的matrixWorld,并将这个值重置为false。默认值为false。

五、窗口 resize事件更新

参考Three.js自适应窗口变化渲染

  • 发生场景:当窗口大小发生变化时,会出现局部空白区域。
  • 解决方法:重新获取浏览器窗口新的宽高尺寸,然后通过新的宽高尺寸更新相机Camera和渲染器WebGLRenderer的参数。
  • 要注意一下,Three.js自适应渲染不一定就是窗口变化,本质上还是你要渲染的区域宽高尺寸变化了;更进一步变化是视图矩阵.matrixWorldInverse和投影矩阵.projectionMatrix的变化。
// onresize 事件会在窗口被调整大小时发生
window.οnresize=function(){
  // 重置渲染器输出画布canvas尺寸
  renderer.setSize(window.innerWidth,window.innerHeight);
  // 全屏情况下:设置观察范围长宽比aspect为窗口宽高比
  camera.aspect = window.innerWidth/window.innerHeight;
  // 渲染器执行render方法的时候会读取相机对象的投影矩阵属性projectionMatrix
  // 但是不会每渲染一帧,就通过相机的属性计算投影矩阵(节约计算资源)
  // 如果相机的一些属性发生了变化,需要执行updateProjectionMatrix ()方法更新相机的投影矩阵
  camera.updateProjectionMatrix ();
};

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

推荐阅读更多精彩内容