Threejs实现酷炫3D地球技术点汇总

前言

ezgif.com-gif-maker

在线预览地址:https://joy1412.cn/online/show3dEarth/

本篇介绍一下如何用Threejs实现一个酷炫的3D地球特效,使用到的技能点如下:

  • 星空动态背景
  • 地球模型
  • 大气层光圈
  • 卫星环绕特效
  • 经纬度坐标转成3D空间坐标
  • 标注以及标注扩散光圈
  • 光柱特效
  • 飞线特效
  • geojson数据生成中国描边以及动态流光效果

正文

这里一个个介绍使用到的技术,首先先搭建初始化界面,把渲染器,相机以及基本的光照设置好。

不懂的可以参考这个页面的模板。

<!DOCTYPE html>
<html lang="en">
<head>
    <title>three.js webgl - mirror</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
    <link type="text/css" rel="stylesheet" href="main.css">
    <style>
        html, body {
            height: 100%;
            width: 100%;
        }
    </style>
</head>
<body>
<div id="container" style="width:100%;height:100vh;position:relative; overflow: hidden;"></div>
</div>
<script type="module">
    import * as THREE from '../build/three.module.js';
    import { OrbitControls } from './jsm/controls/OrbitControls.js';
    let renderer, camera, scene, light, controls;
    const Dom = document.querySelector( '#container' );
    const width = Dom.clientWidth, height = Dom.clientHeight;
    /**
     * @description 初始化渲染场景
     */
    function initRenderer() {
        renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } );
        renderer.setPixelRatio( window.devicePixelRatio );
        renderer.setSize( width, height );
        const containerDom = document.querySelector( '#container' );
        containerDom.appendChild( renderer.domElement );
    }
    /**
     * @description 初始化相机
     */
    function initCamera() {
        camera = new THREE.PerspectiveCamera( 45, width / height, 1, 10000 );
        camera.position.set( 5, - 20, 200 );
        camera.lookAt( 0, 3, 0 );
        window.camera = camera;
    }
    /**
     * @description 初始化场景
     */
    function initScene() {
        scene = new THREE.Scene();
        scene.background = new THREE.Color( 0x020924 );
        scene.fog = new THREE.Fog( 0x020924, 200, 1000 );
        window.scene = scene;
    }
    /**
     * 初始化用户交互
     **/
    function initControls() {
        controls = new OrbitControls( camera, renderer.domElement );
        controls.enableDamping = true;
        controls.enableZoom = true;
        controls.autoRotate = false;
        controls.autoRotateSpeed = 2;
        controls.enablePan = true;
    }
    /**
     * @description 初始化光
     */
    function initLight() {
        const ambientLight = new THREE.AmbientLight( 0xcccccc, 1.1 );
        scene.add( ambientLight );
        var directionalLight = new THREE.DirectionalLight( 0xffffff, 0.2 );
        directionalLight.position.set( 1, 0.1, 0 ).normalize();
        var directionalLight2 = new THREE.DirectionalLight( 0xff2ffff, 0.2 );
        directionalLight2.position.set( 1, 0.1, 0.1 ).normalize();
        scene.add( directionalLight );
        scene.add( directionalLight2 );
        var hemiLight = new THREE.HemisphereLight( 0xffffff, 0x444444, 0.2 );
        hemiLight.position.set( 0, 1, 0 );
        scene.add( hemiLight );
        var directionalLight = new THREE.DirectionalLight( 0xffffff );
        directionalLight.position.set( 1, 500, - 20 );
        directionalLight.castShadow = true;
        directionalLight.shadow.camera.top = 18;
        directionalLight.shadow.camera.bottom = - 10;
        directionalLight.shadow.camera.left = - 52;
        directionalLight.shadow.camera.right = 12;
        scene.add(directionalLight);
    }
    /**
     * 窗口变动
     **/
    function onWindowResize() {
        camera.aspect = innerWidth / innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize( innerWidth, innerHeight );
        renders();
    }

    /**
     * @description 渲染
     */
    function renders() {
        renderer.clear();
        renderer.render( scene, camera );
    }

    /**
     * 更新
     **/
    function animate() {
        window.requestAnimationFrame( () => {
            if (controls) controls.update();
            renders();
            animate();
        } );
    }

    window.onload = () => {
        initRenderer();
        initCamera();
        initScene();
        initLight();
        initControls();
        animate();
        window.addEventListener('resize', onWindowResize, false);
    };
</script>  
</body>
</html>

动态星空背景介绍

image-20210621164543735

作为地球的背景,用动态星空的方式显得更加酷炫,使用原型贴图让原本方形的点模拟球形,再加上动态设置颜色以及设置旋转偏移,更好的模拟星空效果。

  • 随机生成10000个坐标点,设置不同的颜色
const positions = [];
const colors = [];
const geometry = new THREE.BufferGeometry();
for (var i = 0; i < 10000; i ++) {
  var vertex = new THREE.Vector3();
  vertex.x = Math.random() * 2 - 1;
  vertex.y = Math.random() * 2 - 1;
  vertex.z = Math.random() * 2 - 1;
  positions.push( vertex.x, vertex.y, vertex.z );
  var color = new THREE.Color();
  color.setHSL( Math.random() * 0.2 + 0.5, 0.55, Math.random() * 0.25 + 0.55 );
  colors.push( color.r, color.g, color.b );
}
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( positions, 3 ) );
geometry.setAttribute( 'color', new THREE.Float32BufferAttribute( colors, 3 ) );

分别把生成的Vector3和Color放入数组,然后添加到geometry中,这样星空几何体有了。 Color里面setHSL可以设置颜色和饱和度,这里通过random来随机颜色。

  • 使用 ParticleBasicMaterial 生成材质

ParticleBasicMaterial 基础粒子材质用来搭配例子系统,这里我们可以设置粒子的大小,贴图,透明度等设置详细如下:

var starsMaterial = new THREE.ParticleBasicMaterial( {
  map: texture,
  size: 1,
  transparent: true,
  opacity: 1,
  vertexColors: true, //true:且该几何体的colors属性有值,则该粒子会舍弃第一个属性--color,而应用该几何体的colors属性的颜色
  blending: THREE.AdditiveBlending,
  sizeAttenuation: true
} );
  • 使用 ParticleSystem 生成模型
    这里使用ParticleSystem这个粒子系统,是为了提供性能,如果用精灵Particle动态随机生成10000个的话,帧率肯定收到影响,这里ParticleSystem的话,等于只有一个Mesh,能大大提高性能。

把上面生成的几何体geometry 以及材质ParticleBasicMaterial来生成一个ParticleSystem,如下:

let stars = new THREE.ParticleSystem( geometry, starsMaterial );
stars.scale.set( 300, 300, 300 );
scene.add( stars );

地球模型

地球模型比较简单,直接贴图+一个球搞定


image.png
function initEarth() {
  globeTextureLoader.load( './imgs/diqiu2/earth2.jpg', function ( texture ) {
    var globeGgeometry = new THREE.SphereGeometry( radius, 100, 100 );
    var globeMaterial = new THREE.MeshStandardMaterial( { map: texture } );
    var globeMesh = new THREE.Mesh( globeGgeometry, globeMaterial );
    group.rotation.set( 0.5, 2.9, 0.1 );
    group.add( globeMesh );
      scene.add( group );
  } );
}

大气层光圈

大气层光圈这里也是用贴图实现,如下面这张。


image.png

image.png

代码如下

var texture = globeTextureLoader.load( './imgs/diqiu2/earth_aperture.png' );
        var spriteMaterial = new THREE.SpriteMaterial( {
            map: texture,
            transparent: true,
            opacity: 0.5,
            depthWrite: false
        } );
        var sprite = new THREE.Sprite( spriteMaterial );
        sprite.scale.set( radius * 3, radius * 3, 1 );
        group.add( sprite );

卫星环绕特效

image.png

这里用到一个 Mesh 和一个 Poinst 结合,分别用来实现外圈的环形和两个小卫星
光环用 PlaneGeometry 矩形平面即可,加上贴图

globeTextureLoader.load( './imgs/diqiu2/halo.png', function ( texture ) {
            var geometry = new THREE.PlaneGeometry( 14, 14 );
            var material = new THREE.MeshLambertMaterial( {
                map: texture, 
                transparent: true,
                side: THREE.DoubleSide, 
                depthWrite: false
            } );
            var mesh = new THREE.Mesh( geometry, material );
            groupHalo.add( mesh );
        } );

两个环绕的卫星直接使用Points即可,设置两个坐标,用来展示这2个小卫星

globeTextureLoader.load( './imgs/diqiu2/smallEarth.png', function ( texture ) {
            var p1 = new THREE.Vector3( - 7, 0, 0 );
            var p2 = new THREE.Vector3( 7, 0, 0 );
            const points = [ p1,p2];
            const geometry = new THREE.BufferGeometry().setFromPoints( points );
            var material = new THREE.PointsMaterial( {
                map: texture,
                transparent: true,
                side: THREE.DoubleSide, 
                size: 1, 
                depthWrite: false
            } );
            var earthPoints = new THREE.Points( geometry, material );
            groupHalo.add( earthPoints );
        } );
        groupHalo.rotation.set( 1.9, 0.5, 1 );

经纬度坐标转成3D空间坐标

上面已经实现了最基本的效果,接下来需要在地球上添加一下特效,比如标注、光柱、光圈等。

但是会存在一个问题,要定义地球上的一点,用经纬度是常用的办法,但是经纬度又不适合在Threejs上面使用,所以这里就需要先做一步转换工作,把经纬度坐标转换成xyz空间坐标。

这边直接提供两种转换方法:

  • 方法一:js方法转换
/**
*lng:经度
*lat:维度
*radius:地球半径
*/
function lglt2xyz(lng, lat, radius) {
  const phi = (180 + lng) * (Math.PI / 180)
  const theta = (90 - lat) * (Math.PI / 180)
  return {
    x: -radius * Math.sin(theta) * Math.cos(phi),
    y: radius * Math.cos(theta),
    z: radius * Math.sin(theta) * Math.sin(phi),
  }
}
  • 方法二:threejs自带
/**
*lng:经度
*lat:维度
*radius:地球半径
*/
lglt2xyz(lng, lat, radius) {
  const theta = (90 + lng) * (Math.PI / 180)
  const phi = (90 - lat) * (Math.PI / 180)
  return (new THREE.Vector3()).setFromSpherical(new THREE.Spherical(radius, phi, theta))
}

标注以及标注扩散光圈

要实现标注功能很简单,直接一个平面加贴图即可
这里唯一要注意的就是,在球面上的物体,需要设置好角度,不然你会发现效果会和你预想的不一样
具体参考下面方法:

function createPointMesh( pos, texture ) {
        var material = new THREE.MeshBasicMaterial( {
            map: texture,
            transparent: true, //使用背景透明的png贴图,注意开启透明计算
            // side: THREE.DoubleSide, //双面可见
            depthWrite: false, //禁止写入深度缓冲区数据
        } );
        var mesh = new THREE.Mesh( planGeometry, material );
        var size = radius * 0.04;//矩形平面Mesh的尺寸
        mesh.scale.set( size, size, size );//设置mesh大小
        //设置mesh位置
        mesh.position.set( pos.x, pos.y, pos.z );
        // mesh在球面上的法线方向(球心和球面坐标构成的方向向量)
        var coordVec3 = new THREE.Vector3( pos.x, pos.y, pos.z ).normalize();
        // mesh默认在XOY平面上,法线方向沿着z轴new THREE.Vector3(0, 0, 1)
        var meshNormal = new THREE.Vector3( 0, 0, 1 );
        // 四元数属性.quaternion表示mesh的角度状态
        //.setFromUnitVectors();计算两个向量之间构成的四元数值
        mesh.quaternion.setFromUnitVectors( meshNormal, coordVec3 );
        return mesh;
    }

光圈的话,贴图用一张有渐变效果的png图


image.png

然后也是贴在 PlaneBufferGeometry 上,和上面标注的效果实现一样,最后要在渲染函数 animate 里面动态的修改尺寸和透明度即可。

动画效果参考如下代码,WaveMeshArr是所有光圈mesh的数组集合。

if (WaveMeshArr.length) {
    WaveMeshArr.forEach( function ( mesh ) {
        mesh._s += 0.007;
        mesh.scale.set( mesh.size * mesh._s, mesh.size * mesh._s, mesh.size * mesh._s );
        if (mesh._s <= 1.5) {
           //mesh._s=1,透明度=0 mesh._s=1.5,透明度=1
          mesh.material.opacity = ( mesh._s - 1 ) * 2;
        } else if (mesh._s > 1.5 && mesh._s <= 2) {
           //mesh._s=1.5,透明度=1 mesh._s=2,透明度=0
           mesh.material.opacity = 1 - ( mesh._s - 1.5 ) * 2;
        } else {
           mesh._s = 1.0;
       }
    } );
 }

光柱特效

你如果想在Three.js创建一个光柱效果,可以通过Three.js的矩形平面几何体PlaneGeometry创建一个网格模型,然后把一个背景透明的.png格式图片作为矩形网格模型的纹理贴图。

var plane = new THREE.PlaneGeometry(50,200)
var material = new THREE.MeshPhongMaterial({
  //设置矩形网格模型的纹理贴图(光柱特效)
    map: textureLoader.load('光柱.png'),
    // 双面显示
    side: THREE.DoubleSide,
    // 开启透明效果,否则颜色贴图map的透明不起作用
    transparent: true,
});
var mesh = new THREE.Mesh(plane, material);

为了增强立体效果,可以创建两个矩形网格模型然后90度交叉即可

// 矩形网格1
var mesh1 = new THREE.Mesh(plane, material);
// 克隆网格模型mesh1,并旋转90度
var mesh2 = mesh1.clone().rotateY(Math.PI/2)
var groupMesh= new THREE.Group()
groupMesh.add(mesh1,mesh2);

最终实现效果如下


image.png

飞线特效

飞线这里分两块介绍,一个是绘制三维三次贝赛尔曲线,另一个是飞线上模拟物体移动。

  • 飞线

    上面介绍标注的时候,就已经知道了不同位置的坐标了,这里封装了一个方法,传入2个坐标就可以生成一条贝赛尔曲线。

    目前取第一个坐标点作为飞线的起始点,比如你选择北京作为原始点,那飞线特效就是从北京飞往各个地方。

    线条的话,我这里使用Line2,因为这个支持设置线条的宽度。

    核心代码如下:

function addLines( v0, v3 ) {
        // 夹角
        var angle = ( v0.angleTo( v3 ) * 1.8 ) / Math.PI / 0.1; // 0 ~ Math.PI
        var aLen = angle * 0.4, hLen = angle * angle * 12;
        var p0 = new THREE.Vector3( 0, 0, 0 );
        // 法线向量
        var rayLine = new THREE.Ray( p0, getVCenter( v0.clone(), v3.clone() ) );
        // 顶点坐标
        var vtop = rayLine.at( hLen / rayLine.at( 1 ).distanceTo( p0 ) );
        // 控制点坐标
        var v1 = getLenVcetor( v0.clone(), vtop, aLen );
        var v2 = getLenVcetor( v3.clone(), vtop, aLen );
        // 绘制三维三次贝赛尔曲线
        var curve = new THREE.CubicBezierCurve3( v0, v1, v2, v3 );
        var geometry = new LineGeometry();
        var points = curve.getPoints( 50 );
        var positions = [];
        var colors = [];
        var color = new THREE.Color();

        /**
         * HSL中使用渐变
         * h — hue value between 0.0 and 1.0
         * s — 饱和度 between 0.0 and 1.0
         * l — 亮度 between 0.0 and 1.0
         */
        for (var j = 0; j < points.length; j ++) {
            // color.setHSL( .31666+j*0.005,0.7, 0.7); //绿色
            color.setHSL( .81666+j,0.88, 0.715+j*0.0025); //粉色
            colors.push( color.r, color.g, color.b );
            positions.push( points[j].x, points[j].y, points[j].z );
        }
        geometry.setPositions( positions );
        geometry.setColors( colors );
        var matLine = new LineMaterial( {
            linewidth: 0.0006,
            vertexColors: true,
            dashed: false
        } );

        return {
            curve: curve,
            lineMesh: new Line2( geometry, matLine )
        };
}
  • 物体移动特效

物体移动就是从飞线的起始点,飞到物体的终点。

把上面飞线生成的曲线curve,添加到数组里面。

然后循环这个数组,每个数组配套生成一个几何球体,用来当做移动的载体,再把这个球体放入一个数组。

关键来了,循环这个球体数组,把上面curve这条线段再等分100份,然后让球体坐标分别设置为上面等分100份的坐标即可,这样就可以看到球体开始循环移动了。

核心代码如下:

for (let i = 0; i < animateDots.length; i ++) {
  const aGeo = new THREE.SphereGeometry( 0.03, 0.03, 0.03 );
  const aMater = new THREE.MeshPhongMaterial( { color: '#F8D764' } );
  const aMesh = new THREE.Mesh( aGeo, aMater );
  aGroup.add( aMesh );
  }
    var vIndex = 0;
  function animateLine() {
      aGroup.children.forEach( ( elem, index ) => {
    const v = animateDots[index][vIndex];
      elem.position.set( v.x, v.y, v.z );
  });
  vIndex ++;
  if (vIndex > 100) {
      vIndex = 0;
  }
  setTimeout( animateLine, 20 );
 }
 group.add( aGroup );
 animateLine();

geojson数据生成中国描边以及动态流光效果

这块其实可以单独拿出来介绍,根据geojson数据生成地图用的,不过复杂的是用来做拉升,生成几何体模型用的,这边只是简单的给地图画线条。

另外就是用到的一个线条流光的shader特效,这里分开来介绍。

geojson数据可以去这个网站下载:https://datav.aliyun.com/tools/atlas/index.html

  • 根据geojson数据画线条

    上面已经有过画线的经验了,所以这块做起来也很方便,主要2个地方要注意
    1:加载读取geojson数据,循环后把经纬度转化成空间xyz坐标。
    2:根据这些坐标,使用Line生成线条即可。

    核心代码:

function initMap( chinaJson ) {
        // 遍历省份构建模型
        chinaJson.features.forEach( elem => {
            // 新建一个省份容器:用来存放省份对应的模型和轮廓线
            const province = new THREE.Object3D();
            const coordinates = elem.geometry.coordinates;
            coordinates.forEach( multiPolygon => {
                multiPolygon.forEach( polygon => {
                    const lineMaterial = new THREE.LineBasicMaterial( { color: 0XF19553 } ); //0x3BFA9E
                    const positions = [];
                    const linGeometry = new THREE.BufferGeometry();
                    for (let i = 0; i < polygon.length; i ++) {
                        var pos = lglt2xyz( polygon[i][0], polygon[i][1] );
                        positions.push( pos.x, pos.y, pos.z );
                    }
                    linGeometry.setAttribute( 'position', new THREE.Float32BufferAttribute( positions, 3 ) );
                    const line = new THREE.Line( linGeometry, lineMaterial );
                    province.add( line );
                } );
            } );
            map.add( province );
        } );
        group.add( map );
    }
  • 根据geojson设置流光效果

画完中国的描边后,我们还可以让中国的外边框轮廓有动态流光效果,这里还是需要下载中国外边框的geojson数据,用上面那个地址即可,把包含子区域的选项去掉打钩就行。

这里和上面生成的方法不一样,上面最终生成的是线条Line,而这里最终生成的是点Points,然后通过shader处理生成跑动的流光效果。

核心shader写法如下

uniforms:

const singleUniforms = {
            u_time: uniforms2.u_time,
            number: { type: 'f', value: number },
            speed: { type: 'f', value: speed },
            length: { type: 'f', value: length },
            size: { type: 'f', value: size },
            color: { type: 'v3', value: color }
};

顶点着色器和片元着色器:

<script id="vertexShader2" type="x-shader/x-vertex">
    varying vec2 vUv;
    attribute float percent;
    uniform float u_time;
    uniform float number;
    uniform float speed;
    uniform float length;
    varying float opacity;
    uniform float size;
    void main()
    {
        vUv = uv;
        vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
        float l = clamp(1.0-length,0.0,1.0);
        gl_PointSize = clamp(fract(percent*number + l - u_time*number*speed)-l ,0.0,1.) * size * (1./length);
        opacity = gl_PointSize/size;
        gl_Position = projectionMatrix * mvPosition;
    }
</script>
<script id="fragmentShader2" type="x-shader/x-vertex">
    #ifdef GL_ES
    precision mediump float;
    #endif
    varying float opacity;
    uniform vec3 color;
    void main(){
        if(opacity <=0.2){
            discard;
        }
        gl_FragColor = vec4(color,1.0);
    }
</script>

效果如下:


ezgif.com-gif-maker (1).gif

总结

以上就是这篇3D地球的技术拆解,把每个技术点都掌握了,后期就能配合开发出更多的特效了。
希望对你有所帮助。

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

推荐阅读更多精彩内容