WebGL学习笔记

WebGL从2012年开始接触,后面因为开始专注前端其他方面的事情,慢慢地就把它给遗忘。最近前端开始又流行起绘画制作,游戏、VR等等又开始引起前端人们的注意。所以,是时候开始重新拾起。
3D绘画是一个很复杂的数学物理综合体,会涉及到很多基础概念,了解了这些概念后才能进行实际的开发工作。

基础概念

  • WebGL不同于canvas,它是一个三维绘画模拟技术,在我们二维的显示器里,其由深度决定前后的关系,根据远近进行放大和缩小,通过坐标变换(模型变换,视图变换和投影变换)组合描绘出画面的内容。
  • WebGL中,所谓的固定渲染管线(简单来说是3D渲染所进行的一连串的计算流程)是不存在的,所有的坐标变换必须用自己全部完成,而且,这个计述坐标变换的机制就叫做着色器(Shader),这样可以由程序员控制的机制就叫做可编辑渲染管线。而着色器又有处理几何图形顶点的顶点着色器和处理像素的片段着色器两种类型。
  • 在HTML中,着色器是利用script标签进行定义存放
<script id="vshader" type="x-shader/x-vertex">  
    ※顶点着色器  
</script>  
<script id="fshader" type="x-shader/x-fragment">  
    ※片段着色器  
</script> 
  • 3D渲染变换绘制和演变使用的是4x4的矩阵,也就是行数和列数同为4的矩阵,在数学中我们都知道它叫方阵。实际3D渲染时,准备个各种坐标变换的矩阵,然后相乘,将最终得到的矩阵传给WebGL的顶点着色器。顶点着色器从传过来的矩阵中,获得模型的坐标,加工到画面上显示出来。也就是说,操作坐标变换的矩阵,就可以决定模型在画面上如何绘制。
  • WebGL的世界里任何东西都是可以描画,但是描画的最基本东西也就是点、线段和三角形。顶点,就是三维空间上存在的一个点,包括X,Y,Z坐标信息。顶点的连接顺序是判断3D绘画多边形的基准的。顺时针连接顶点的多边形是在外侧,而逆时针连接的多边形在内侧。为了减少处理,3D世界里看不到的东西不绘制,而这种机制就叫做遮挡剔除,如果我们设定了遮挡剔除,那么WebGL就只绘制外侧看得见的东西,内侧所有多边形就都不会再绘制。

基础代码

页面初始化

<html>  
    <head>  
        <title>WebGL TEST</title>  
    </head>  
    <body>  
        <canvas id="canvas"></canvas>  
    </body>  
</html>

这段HTML代码,纯粹只是在页面上放置一个canvas,我们将从这个canvas中获取context,然后进行WebGL初始化。
获取context
首先获取canvas对象并设置其大小

var c = document.getElementById('canvas');  
c.width = 500;  
c.height = 300; 

获取WebGL的context

// 兼容处理
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');  

画面初始化
WebGL的context和普通的canvas是一样的,包含了绘画相关的各种各样的处理对象、函数、常量和属性。例如:

// 使用指定常量颜色来清空画面
gl.clear(gl.COLOR_BUFFER_BIT);
// 使用颜色值(RGBA)来清空画面
gl.clearColor(0.0,0.0,0.0,1.0);

上面几步简单的代码块就能够完成一个几步的WebGL使用,整个代码运行起来就是一个大小为500*300的黑色块画面。

认识GLSL

我们已经知道WebGL是无法利用固定渲染管线的,所以代替它的是可编辑渲染管线中的一种着色语言,叫做GLSL(OpenGL Shading Language)。
GLSL使用C语言为基础,并且有自己独立的语法。WebGL编程难点之一也就是这个GLSL的使用。

  • WebGL里有顶点着色器和片段着色器两种着色器。无论哪一种都可以使用GLSL来编写。顶点着色器和片段着色器是相互依赖的,缺一不可,并且首先被调用的是顶点着色器。我们可以把顶点相关的情报信息(位置、法线、纹理坐标、颜色等)传递给顶点着色器去处理我们要绘制的顶点。而片段着色器则是决定画面用什么颜色输出。片段着色器英文是fragment,其实就是断片,碎片的意思。而画面上的像素实际上就是最小的断片,总而言之,片段着色器操作的是颜色。
  • GLSL编写基础
    首先,不管是顶点着色器还是片段着色器,都必须定义一个main函数,函数里记录你要做的处理。而且,顶点着色器的话,必须要把顶点信息传给一个叫做gl_Position的变量。例如:
// attibute修饰符是用来接收不同顶点传来的不同信息
// vec*表示的是向量,*部分是一个2~4的数字,vec3表示的是一个3维的向量。其元素是浮点型
// position变量定义顶点信息
attribute vec3 position;  
void main(void) {  
    gl_Position = position;  
}

在WebGL中,顶点相关处理就是坐标变换,模型变换、视图变换和投影变换也就是顶点着色器的工作之一。一般来说,WebGL程序中,首先生成模型、视图、投影的各个矩阵,然后进行合并,最后将得到的坐标变换的矩阵传给顶点着色器。这时,我们定义传递这些矩阵值:

attribute vec3 position; 
// uniform修饰符是用来接收所有顶点一致的情报信息
// mat*表示的是方阵,可指定范围2~4,mat4表示的是4x4的方阵。其元素是浮点型
uniform mat4 mvpMatrix;  
void main(void) {  
    gl_Position = mvpMatrix * position;  
} 

顶点着色器与片段着色器的连接
GLSL里还有一个重要的修饰符,也就是varying修饰符,是用来连接顶点着色器和片段着色器之间的桥梁。
比如要把绘制的模型变成半透明,要怎么做?
方法虽然有很多,但是一般的做法是,向顶点里添加颜色的情报信息,然后通过操作颜色的透明度的变化来使模型半透明或者完全透明。这时候,如果想操作顶点里的颜色信息和画面上的颜色信息的话,就需要向片段着色器里传入一些必要的信息。首先是顶点着色器部分:

attribute vec4 position;  
attribute vec4 color;  
uniform mat4 mvpMatrix;  
varying vec4 vColor  
void main(void) {  
    vColor = color;  
    gl_Position = mvpMatrix * position;  
}

接着,片段着色器接收通过varying修饰符所定义的变量vColor:

varying vec4 vColor;  
void main(void)  {  
    gl_FragColor = vColor;  
}

和顶点着色器中必需要把数据传给gl_Position类似,片段着色器要把数据传给gl_FragColor,只是与顶点着色器不同的是,片段着色器的gl_FragColor不是必须要赋值的。但是一般都会输出一种什么颜色,所以gl_FragColor就变成必要的了。

顶点缓存

局部坐标
       顶点最终在画面上绘制的时候,要经过模型坐标变换,视图坐标变换和投影坐标变换,这个已经说过好多遍了。但是,在使用坐标情报之前,首先必须定义这些顶点群的构成,否则就没有办法开始了。定点群放到什么位置,就表现为坐标,一般叫做局部坐标。局部坐标就是模型的各个顶点相对于原点(x,y,z都为0)的坐标。比如,一个局部坐标为(1.0,0.0,0.0)的顶点,x轴方向距离原点的距离是1.0。同样,各个顶点都依次定义了局域坐标,这样顶点的位置就形成了。

顶点保存
       这些顶点的局部坐标,必须在WebGL程序中进行变换,然后传给顶点着色器。在WebGL中,为了处理这些顶点的信息,并将这些顶点信息保存,则需要使用顶点缓存。缓存(buffer),是表示数据保存空间的一般的计算机用语。WebGL中还有帧缓存,索引缓存等各种缓存,但是不管哪种缓存,你只需要把它想成保存数据的一块儿空间就行了。顶点缓存是其中的一种,就是用来保存顶点信息的,WebGL中的顶点缓存叫做VBO(vertex buffer object)。

顶点缓存和attribute
       WebGL的程序中,先把顶点的信息保存到VBO中,接着,通知着色器哪个VBO和哪个attribute变量相关,然后顶点着色器就可以正确的处理这些顶点了。根据前面的内容,顶点缓存相关的处理的具体流程如下:

  • 顶点的各种信息保存到数组里
  • 使用WebGL的方法生成VBO
  • 使用WebGL的方法将数组中的信息传给VBO
  • 顶点着色器中的attribute函数和VBO结合

VBO的生成过程中,首先在最初的时候必须把数据保存到数组中,因为顶点的信息(位置)中必须有x,y,z,所以数组的长度必须是顶点数x3,这个时候需要注意,数组不可以使用多维数组,VBO的生成需要使用一维数组。准备好保存顶点信息的数组之后,使用WebGL的context的方法生成VBO,当然生成的时候VBO是空的,然后将顶点信息的数组传给它。然后,比如把顶点着色器中的attribute函数和VBO关联起来。上面也说了,VBO中不是只能保存一种信息,位置情报以外的法线和颜色等信息存在的时候,要准备合适的VBO,然后通知WebGL哪个VBO和哪个attribute变量相关联。

矩阵计算和外部库

矩阵计算
       矩阵的计算方法,也不是什么特别奇怪复杂的东西,如果数学好好学习的话,没有基础也可以进行基本的矩阵计算。但是,如果不知道矩阵的加法和乘法运算的话,要进行稍微复杂一些的矩阵计算是非常难的。
矩阵的使用方法,并不是详细的计算方法。特别是在3D开发中,矩阵能够做什么,通过什么运算能得到什么样的结果,主要是掌握矩阵的使用方法,这一点很重要。

外部库
       DirectX和OpenGL中,内置了许多矩阵相关的处理,即使不使用外部库也可以进行矩阵计算。但是,WebGL中这些矩阵相关的计算是没有的,可能为了简化吧,当然,不是说没有办法了,而是,矩阵相关的一切计算,都需要自己来处理。话虽如此,但是WebGL中的矩阵计算还是一个很大的问题。数学好的人当然是没有问题了,但是对于其他人数学不太好的人就太困难了。但是,不用怕,有很多使用JavaScript写的矩阵计算的外部库,使用这些外部库的话,就算自己不会矩阵计算,也可以进行矩阵相关的处理,下面是其中的几个:

着色器的编译和连接

用个绘制多边形的例子来开始吧,首先,确认一下绘制的步骤:

  • 从HTML中获取canvas对象
  • 从canvas中获取WebGL的context
  • 编译着色器
  • 准备模型数据
  • 顶点缓存(VBO)的生成和通知
  • 坐标变换矩阵的生成和通知
  • 发出绘图命令
  • 更新canvas并渲染

步骤1、2和着色器的定义代码之前的“基础代码”和“认识GLSL”已经学习过,再次整理一下:

<html>  
  <head>  
      <title>WebGL TEST</title>  
  </head>  
  <body>  
      <canvas id="canvas"></canvas>
      <!-- ※顶点着色器 -->
      <script id="vs" type="x-shader/x-vertex">  
           attribute vec3 position;  
           uniform   mat4 mvpMatrix;  
           void main(void){  
              gl_Position = mvpMatrix * vec4(position, 1.0);  
           }  
      </script>  
      <!-- ※片段着色器 -->  
      <script id="fs" type="x-shader/x-fragment">  
            void main(void){  
              gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);  
            }  
      </script>
      <script>
           //首先获取canvas对象并设置其大小
           var c = document.getElementById('canvas');  
           c.width = 500;  
           c.height = 300;
           // 从canvas中获取WebGL的context
           var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
      </script>    
  </body>  
</html>

编译着色器
       编译也不需要什么特别的编译器,只需要调用WebGL内部的函数就可以进行编译了。准备一个函数,从着色器的编译,到实际着色器的生成这一连串的流程,都在这一个函数中来完成。下面是这个函数的代码:

function create_shader(id){  
    // 用来保存着色器的变量  
    var shader;  
    // 根据id从HTML中获取指定的script标签  
    var scriptElement = document.getElementById(id);  
    // 如果指定的script标签不存在,则返回  
    if(!scriptElement){return;}  
    // 判断script标签的type属性  
    switch(scriptElement.type){  
        // 顶点着色器的时候  
        case 'x-shader/x-vertex':  
            shader = gl.createShader(gl.VERTEX_SHADER);  
            break;  
        // 片段着色器的时候  
        case 'x-shader/x-fragment':  
            shader = gl.createShader(gl.FRAGMENT_SHADER);  
            break;  
        default :  
            return;  
    }  
    // 将标签中的代码分配给生成的着色器  
    gl.shaderSource(shader, scriptElement.text);  
    // 编译着色器  
    gl.compileShader(shader);  
    // 判断一下着色器是否编译成功  
    if(gl.getShaderParameter(shader, gl.COMPILE_STATUS)){  
        // 编译成功,则返回着色器  
        return shader;  
    }else{
        // 编译失败,弹出错误消息  
        alert(gl.getShaderInfoLog(shader));  
    }  
}  

程序对象的生成和连接
       使用varying修饰符定义的变量,可以从顶点着色器向片段着色器中传递数据。其实,实现从一个着色器向另一个着色器传递数据的,不是别的,就是程序对象。程序对象是管理顶点着色器和片段着色器,或者WebGL程序和各个着色器之间进行数据的互相通信的重要的对象。
那么,生成程序对象,并把着色器传给程序对象,然后连接着色器,将这些处理函数化:

function create_program(vs, fs){  
    // 程序对象的生成  
    var program = gl.createProgram();  
    // 向程序对象里分配着色器  
    gl.attachShader(program, vs);  
    gl.attachShader(program, fs);  
    // 将着色器连接  
    gl.linkProgram(program);  
    // 判断着色器的连接是否成功  
    if(gl.getProgramParameter(program, gl.LINK_STATUS)){
        // 成功的话,将程序对象设置为有效  
        gl.useProgram(program);  
        // 返回程序对象  
        return program;  
    }else{
        // 如果失败,弹出错误信息  
        alert(gl.getProgramInfoLog(program));  
    }  
}  

VBO的生成
      生成VBO的时候使用WebGL的createBuffer函数,这个函数就是用来生成缓存的。但是这个函数并不是用来直接生成VBO的,它只是生成了一个缓存对象,根据它里面保存的内容不同,用途也是不用的。
要操作缓存,首先必须跟WebGL进行绑定,就是说,要向“缓存”这个“光盘”中写入数据的时候,必须连接到WebGL这个“光驱”上。
绑定了缓存之后,使用bufferData函数来向缓存中写入数据,把这些处理写成一个函数,就是下面这样:

function create_vbo(data){  
    // 生成缓存对象  
    var vbo = gl.createBuffer();  
    // 绑定缓存  
    gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
    // 向缓存中写入数据  
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);    
    // 将绑定的缓存设为无效  
    gl.bindBuffer(gl.ARRAY_BUFFER, null); 
    // 返回生成的VBO  
    return vbo;  
}  

这个函数,接受一个矩阵作为参数,最后返回生成的VBO。首先使用createBuffer生成缓存对象,接着绑定缓存,然后写入数据。
绑定缓存的时候使用bindBuffer函数,这个函数有两个参数,第一个参数是缓存的类型,第二个参数是指定缓存对象。将第一个参数指定为gl.ARRAY_BUFFER就可以生成VBO。
另外,bufferData函数的第二个参数中出现的Float32Array对象,是javascript的类型数组,和一般的Array对象类似,是处理浮点型小数的时候使用的数组对象。3D世界里小数的精确度非常重要,所以使用类型数组来传递数据。而第三个参数中的gl.STATIC_DRAW这个常量,定义了这个缓存中内容的更新频率。VBO的话,模型数据基本上就是直接这么反复用,所以使用这个常量。
可以绑定WebGL的缓存,一次只能绑定一个,所以要操作其他的缓存的时候,必须要绑定相应的缓存。所以在函数的最后,再次使用bindBuffer函数,设定第二个参数为null,来将上次的绑定无效化,这是为了防止WebGL中的缓存一致保留,而出现和预想不一致的情况。
坐标变换矩阵的基本功能
      进行基本的3D渲染的时候,需要准备3个坐标变换矩阵。
第一个是模型变换矩阵,DirectX中叫做世界变换矩阵。模型变换矩阵影响的是所绘制的模型,模型的位置,模型的旋转,模型的放大和缩小等相关的情况。
第二个是视图变换矩阵,简单来说,就是定义拍摄3D空间的镜头(摄像机),决定了镜头的位置,镜头的参考点,镜头的方向等。
第三个是投影变换矩阵,这个坐标变换定义了屏幕的横竖比例,剪切的领域等,另外获取远近法则的效果也需要用这个变换矩阵。
根据这些内容,差不多知道了需要对矩阵进行哪些操作。使用minMatrix.js可以对矩阵进行基本的操作,来看一下minMatrix.js都能完成哪些操作吧。

  • minMatrix.js的基本功能
    minMatrix.js包含矩阵的生成和矩阵的基本操作,minMatrix.js的核心是一个叫做matIV的对象,通过这个对象可以进行所有的矩阵操作,使用minMatrix.js来操作矩阵的时候,首先,需要生成一个matIV对象:
var m = new matIV();

像上面这样,变量m就是matIV对象的一个实例,通过m.方法名可以调用matIV对象中存在的方法。
下面,列举一下minMatrix.js中定义的matIV对象的方法:

.create
函数: matIV.create()
参数: 无
返回值:    矩阵 
生成一个4x4的方阵,里面包含16个元素,其实是一个Float32Array对象,所有的元素都被初始化为0
.identity
函数: matIV.identity(dest)
参数: dest > 初始化的矩阵
返回值:    初始化后的矩阵
将接收的矩阵参数进行初始化并返回
.multiply
函数: matIV.multiply(mat1,mat2,dest)
参数: mat1 > 相乘的原始矩阵
参数: mat2 > 作为乘数的矩阵
参数: dest > 用来保存计算结果的矩阵
mat1在左,mat2在右,相乘后的结果保存到dest中
.scale
函数: matIV.scale(mat,vec,dest)
参数: mat > 原始矩阵
参数: vec > 缩放向量
参数: dest > 用来保存计算结果的矩阵
模型变换中的放大缩小,mat是原始矩阵,vec是X,Y,Z的各个缩放值组成的向量,最后的计算结果保存在dest中
.translate
函数: matIV.translate(mat,vec,dest)
参数: mat > 原始矩阵
参数: vec > 表示从原点开始移动一定距离的向量
参数: dest > 用来保存计算结果的矩阵
模型变换中的坐标移动,mat是原始矩阵,vec是X,Y,Z的各个方向上的移动量组成的向量,最后将计算结果保存到dest中
.rotate
函数: matIV.rotate(mat,angle,axis,dest)
参数: mat > 原始矩阵
参数: angle > 旋转角度
参数: axis > 旋转轴的向量
参数: dest > 用来保存计算结果的矩阵
模型变换中的旋转,mat是原始矩阵,angle是旋转角度,axis是旋转轴向量,最后将计算结果保存到dest中
.lookAt
函数: matIV.lookAt(eye,center,up,dest)
参数: eye > 镜头位置向量
参数: center > 镜头参考点的向量
参数: up > 镜头的方向向量
参数: dest > 用来保存计算结果的矩阵
视图变换矩阵的生成,eye是镜头在三维空间中的位置,center是这个镜头的参考点,up是镜头的方向向量,最后将计算结果保存到dest中
.perspective
函数: matIV.perspective(fovy,aspect,near,far,dest)
参数: fovy > 视角
参数: aspect > 屏幕的宽高比例
参数: near > 近截面的位置
参数: far > 远截面的位置
参数: dest > 用来保存计算结果的矩阵
投影变换矩阵的生成,这里生成的是一般被称为[透视射影]的投影变换矩阵,包含远近法则。fovy是视角,aspect是屏幕的横竖比例,near是近截面的位置(必须是大于0的数值),far远截面的位置(任意数值),最后将计算结果保存到dest中
.transpose
函数: matIV.transpose()
参数: mat > 原始矩阵
参数: dest > 用来保存计算结果的矩阵
矩阵的行列互换,将计算结果保存到dest中
.inverse
函数: matIV.inverse(mat,dest)
参数: mat > 原始矩阵
参数: dest > 用来保存计算结果的矩阵
求矩阵的逆矩阵,mat是原始矩阵,求的的逆矩阵保存到dest中
  • 矩阵变换的流程
    使用minMatrix.js的话,可以操作矩阵,那么先来确认一下操作顺序。
    模型变换也好,视图变换,投影变换也好,如果不先生成矩阵的话,就什么也做不了。所以首先执行matIV.create生成矩阵,然后通过matIV.identity来初始化矩阵,代码如下:
// 生成matIV对象  
var m = new matIV();  
// 矩阵生成及初始化  
var Matrix = m.identity(m.create());

(未完待续)
注:学习内容来自于https://wgld.org/

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

推荐阅读更多精彩内容