openGL 学习笔记 (二) 使用GL API 绘制出属于自己的矩形

在OpenGL中所有的事物都是在3D空间中,但是我们所看到的屏幕成像却是2D的像素数组。这导致OpenGL的大部分工作就是把得到的3D坐标转换为适应屏幕的2D图像。转换的整个处理过程是由OpenGL的图形渲染管线管理的。

OpenGL图形渲染管线:

1> 指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程
2> 图形渲染管线可以被划分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。
3> 图形渲染管线可以划分为几个阶段,每个阶段都是把上个阶段的输出当做输入来处理的。由于每隔阶段的处理都是高度专门化的,所以很容易并行执行,所以在GPU中可用成千上万的小处理核心可以为每个管线阶段的着色器并行处理。
4> OpenGL的渲染管线大致可分为以下几个阶段:
定点数据输入——> >图元(片元)装配——>几何着色器——>光栅化——>片段着色器——>Alpha测试与颜色混合
5> 有些着色器语序可以通过开发者自己实现,从而达到更细致的控制图像的渲染结果和速度。其中顶点着色器,几何着色器,片段着色器是我们可以重新实现的部分。

简略概括:
顶点数据输入: 首先将顶点数据以数组的形式将n个3D坐标作为整个图形渲染管线的输入。顶点数据是一系列顶点的集合。一个顶点是一个3D坐标的数据的集合。在OpenGL中顶点数据是用顶点属性表示的。他可以包含任何可能呢会用到的数据。

顶点着色器: 接受一个单独的顶点进行输入,主要工作是将3D坐标变为另一种3D坐标,他接受开发者自己实现。

图元(片元)装配:

1> OpenGL需要去指定我们想要绘制的图像的坐标和颜色构成,是一系列的点,或是一系列的三角形,还是一系列的线。这些最基础的构成被称为图元(片元)。 // TODO eg:
2> 图元装配将顶点着色器的所有输出作为输入,(注意,如果图元类型为GL_PIONTS,则只接受一个顶点)。然后将所有的点装配成指定好的图元形状。

几何着色器: 接受图元形式的一系列顶点作为输入,通过产生新顶点构造出新的图元生成其他图元。

光栅化: 将图元映射为最终屏幕上相应的像素,生成供片段着色器使用的片段,并进行裁切,将超出你的视图以外的所有像素丢弃,用来提升执行效率。

片段着色器:

1> 这里的片段是指OpenGL渲染一个像素所需的所有数据。
2> 片段着色器将计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(如光照、阴影、光的颜色等),这些数据可以被用来计算该片段最终像素的颜色。

Alpha测试与颜色混合: 该阶段会检测对应片段对应的深度值,用来判断这个片段是否存在于其他的片段之前或者之后,从而判断该片段是否要被丢弃。同时也会检查Alpha(透明度)值并对片段进行颜色混合

详细过程及代码实现:

定义顶点数据

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
    0.5f, -0.5f, 0.0f,
    0.0f,  0.5f, 0.0f
};

(OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。)

将定义的定点数据作为输入发送给顶点着色器,顶点着色器在GPU上创建内存用于储存顶点数据,还要配置OpenGL如何解释这些内存,并且指定其如何发送给显卡,顶点着色器接着会处理我们在内存中指定好数量的顶点。
这个时候如果一个顶点一个顶点的发送,其缺点是显而易见的,将顶点数据从CPU发送至GPU的速度相对较慢。使用顶点缓冲对象来管理这些顶点的内存则可以一次性发送尽可能多的数据。当顶点数据发送至显存中后,顶点着色器几乎能立即访问顶点。


创建一个顶点缓冲对象的过程如下:

首先生成一个缓冲区对象

// generate buffer object names
void glGenBuffers(GLsizei n, GLuint * buffers);

第一个参数是要生成的缓冲对象的数量,第二个是要输入用来存储缓冲对象名称的长度为n的id数组,如果 n == 1, 则只需要一个id。
该函数仅仅生成一个缓冲对象的名称,这个缓冲对象并不具备任何意义,即它仅仅是个缓冲对象,还不是顶点缓冲对象。他就像C中的一个指针变量,可以给他分配内存对象并且用它的名称来引用这个内存对象。

unsigned int VBO;
glGenBuffers(1, &VBO);

顶点缓冲对象的缓冲区类型(缓冲区ID)为 GL_ARRAY_BUFFER,
要指定缓冲对象的类型则需要用到下面的函数

// bind a named buffer object
void glBindBuffer(GLenum target, GLuint buffer);

一个参数是缓冲对象的类型,第二个参数是已经创建的缓冲对象的名称。使用该函数将创建好的VBO对象绑定到OpenGL的上下文环境中,如果绑定的buffer值为零,那么OpenGL将不再对当前target使用任何对象。
需要注意的是函数的第二个参数虽然是Gluint型的。

OpenGL允许我们同一同时绑定多个不同类型的缓冲对象,但是不能绑定两个相同的缓冲对象,这就是上下文之所以用上下文来理解它的意义。

缓冲对象只是OpenGL对象的一种,使用其他对象的思路跟缓冲对象是差不多的,都是创建对象——>绑定上下文——>设置数据

// 创建并初始化缓冲区对象的数据存储
void glBufferData (GLenum target, GLsizeiptr size, const GLvoid * data, GLenum usage);

该函数将删除之前存在于指定类型缓冲区的数据,为当前已绑定的指定类型的缓冲区对象创建一个新的数据存储,第一个参数是指定目标缓冲区类型,有GL_ARRAY_BUFFER(VBO)、GL_ELEMENT_ARRAY_BUFFER(EBO)两种,第二个参数用来指定缓冲区对象的新数据需要的内存大小,第三个参数是我们希望发送的实际数据的指针,第四个参数用来指定给GPU数据的预期使用方式,这使得GL的实现能够做出更明智的选择,可能会影响缓冲区对象的性能,它不会限制数据存储的实际使用。usage可分解为两个部分理解。第一,访问的频率,第二,访问的性质:
访问频率:
STREAM // 数据存储内容将被修改一次并最多使用几次。
STATIC // 数据存储内容将被修改一次并多次使用。
DYNAMIC // 数据存储内容将被重复修改并多次使用。
访问的性质
DRAW // 数据存储内容由应用程序修改,并用作GL绘图和图像规范命令的源。
和在一起则是以下三个
GL_STATIC_DRAW // 数据不会或几乎不会改变。
GL_DYNAMIC_DRAW // 数据会被改变很多。
GL_STREAM_DRAW // 数据每次绘制时都会改变。

到此顶点数据已经被存在GPU内存中了。

顶点着色器:
顶点着色器就要使用着色器语言GLES,用字符串存储,发送给GL,然后编译这个着色器。eg:

#version 330 core
layout (lcoation = 0) in vec3 aPos;
void main()
{
    //注意,如果图元类型为GL_PIONTS,则只接受一个顶点
    sition = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

每个着色器都起始与一个版本声明,同时说明我们使用的渲染模式是核心模式。
in 关键字用来顶点着色器中声明的所有顶点属性,(上面的只有一个顶点位置属性),layout (lcoation = 0) 代表输入变量的位置值。
在主函数中设置着色器的双输出值,这里将gl_Position顶点位置属性进行更改。

然后是编译着色器:

// 创建一个着色器对象
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

// 将着色器源码附加到着色器对象上
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL)
// 第一个参数是创建好的着色器对象,第二个参数指定的传递的源码字符串数量,第三个参数是顶点着色器所需要的源码。
    
// 编译着色器
glCompileShader(vertexShader);

// 检测着色器是否编译编译成功,并获取错误信息。
int  success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED
" << infoLog << std::endl;
}

片段着色器:

片段着色器的编写,编译和顶点着色器的过程大同小异,只是类型变成了GL_FRAGMENT_SHADER。

#version 330 core
out vec4 FragColor;
void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
// 片段着色器只需要一个输出变量,这个变量是一个vec4类型,他代表的是最终的 输出颜色,使用out关键字声明输出变量。
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

两个着色器写完之后,将两个着色器对象链接到一个用来渲染的着色器程序中。
着色器程序对象是多个着色器对象链接在一起的成果。在渲染对象的时候激活需要的着色器程序。在发送渲染调用的时候GL会使用当前激活的着色器程序。
当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个连接错误。

// 创建一个着色器程序对象

// 创建一个着色器程序对象
unsigned int shaderProgram;
shaderProgram = glCreateProgram();

// 将着色器对象加入着色器程序对象并链接
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

// 检查链接成功:
int successs;
char infoLog[512];
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
}

// 在链接成功后删除之前创建的着色器对象,释放内存
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

// 在需要的时候,使用着色器
glUseProgram(shaderProgram);

在glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用这个着色器程序对象。

顶点数据已经发送给了GPU,也指示了GPU在顶点着色器和片段着色器中如何处理它,但是GPU还不知道如何处理已经存在GPU内存中的的顶点数据。即如何将已经在GPU中的顶点是数据以顶点着色器可以接受的方式发送给顶点着色器。顶点着色器可以接受任何以顶点属性形式的输入,所以顶点着色器有着很强的灵活性,但是这样我们必须手动指定输入数据的哪一个部分对应顶点着色器需要的的哪一个顶点属性。所以在开始渲染之前,必选要告诉顶点着色器如何解释顶点数据。

上文在传入的时候,vertices是一个float类型的一维数组,即一段连续的浮点型内存,在GPU中顶点缓冲数据则会被解析为以下特征的连续内存:
1> 每个数据会被存储32位(4字节)的float类型值;
2> 每个位置包含三个这样的值;
3> 存储的内存和传入的时候一样是连续的;
4> 数据的第一个值在缓冲开始的位置,即数组的第0个元素。

通过既定的这个信息,就可以使用glVertexAttribPointer函数来告诉GL如何去解释顶点缓冲对象。eg:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

// 参数含义
1> 第一个参数指定配置的顶点属性,在编写顶点着色器的时候,第二句中有个 layout(location = 0),该语句设置position顶点的属性位置值(location)为0,如果希望把顶点数据传递到这一个顶点属性中,那么这里参数传入0;
2> 第二个参数用来指定顶点属性的大小,因为用到的顶点位置数据为vec3,由三个值组成,大小为3;
3> 第三个参数指定数据的类型;
4> 第四个参数指定是否希望数据被标准化,如果为GL_TRUE,所有传入的数据会被映射到0到1(有符号输为-1到1)之间。
5> 第五个参数可以用“步长”来解释,他用来告诉GL整个顶点属性第二次出现的地方到整个数组0位置之间有多少字节,也可以传入0让GL自己检测,但是只能在传入连续内存(即数组)的情况下使用。
6> 第六个参数用来表示位置数据在缓冲中起始位置的偏移量(Offset),但要注意,改参数的类型为void*,所以要讲类型强制转换为void*。

而 glEnableVertexAttribArray 则以顶点属性位置值作为参数,启用顶点属性,因为顶点属性默认是禁用的。 整套流程下来大概会是这样:

// 创建并绑定顶点缓冲对象
float vertices[] = {
    -0.5f, -0.5f, 0.0f,
    0.5f, -0.5f, 0.0f,
    0.0f,  0.5f, 0.0f
};

glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

// 渲染时,使用着色器程序
glUseProgram(shaderProgram);

// 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();

在绘制时,每绘制一个物体的时候都必须重复这一过程,这个时候绑定正确的缓冲对象,为每次绘制配置正确的顶点属性就变的比较麻烦,为了解决这个麻烦,可以使用顶点数组对象,将所有绘制需要的状态信息储存在一个对象中。

顶点数组对象可以像顶点缓冲对象一样被绑定,任何随后的顶点属性调用都会被储存着这个VAO对象中,这样就等于说是在配置顶点属性指针时,只需要将之前的调用执行一次,在绘制不同的物体时,只需要绑定不同的VAO就可以了。(OPGL的核心渲染模式指定使用VAO对象,如果绑定VAO对象失败,OPGL会拒绝渲染)

一个顶点数组对象将会存储以下内容:
1> glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
2> 通过glVertexAttribPointer设置的顶点属性配置。(也就是数据放在哪里,和如何读取数据)
3> 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。

在内存中大概就是这个意思

VBO1 = {pos[0], pos[1], pos[2], ...}
VBO2 = {pos2[0], pos2[1], pos2[2], ...}
VAO1 = {*pos[0]}

VBO3 = {pos[0], color[0], pos[1], color[1], pos[2], color[2], ...}
VAO2 = {*pos[0], *color[0]}

创建一个VAO对象跟创建一个VBO对象相似:

unsigned int VAO;
glGenVertexArrays(1, &VAO);

在使用glBindVertexArray绑定VAO之后,就可以对VAO对象进行操作。在绑定之后,可把绑定和配置对应的VBO和属性指针然后解绑,供之后使用。在绘制物体的时候只需要把VAO对象绑定到希望使用的设定上,新的使用VAO绘制的流程应该如下:

// 绑定VAO
glBindVertexArray(VAO);

// 将BO复制到GL缓冲区中供GL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// 设置VBO对象
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

// 绘制
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();

// 实际绘制时(在循环中)使用的代码应如下
glUseProgram(shaderProgram);
glBindVertexArray(VAO); //绑定我们需要的VAO,会导致上面所有VAO保存的设置自动设置完成
glDrawArrays();   

// 最后记得解绑不会再使用的VAO对象
glBindVertexArray(0);   //解绑VAO

索引缓冲对象(EBO):
如果是绘制一个矩形,可以使用绘制两个三角形的办法绘制。这会生成下面两个索引缓冲对象:

float vertices[] = {
    // 第一个三角形
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, 0.5f, 0.0f,  // 左上角
    // 第二个三角形
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
}

可以看到,有两对一模一样的顶点,但实际上,一个矩形只有四个顶点这就会产生50%的额外开销。如果模型越来愈大,这个问题会更加糟糕,会产生数不清的浪费。好的解决方案是存储不同的顶点,并设定绘制这些顶点的顺序。这就是索引缓冲对象的(EBO)工作方式和顶点缓冲对象一样EBO是一个缓冲,他专门储存索引,OpenGL调用这些顶点的索引来决定绘制哪个点。

1> 首先,我们要定义不懂位置的顶点,和绘制矩形所需要啊的索引:

float vertices[] = {
  0.5f, 0.5f, 0.0f, // 右上角
  0.5f, -0.5f, 0.0f, // 右下角
  -0.5f, 0.5f, 0.0f, // 左上角
  -0.5f, -0.5f, 0.0f, // 左下角
}

unsigned int indices[] = { // 注意索引从0开始! 
  0, 1, 3, // 第一个三角形
  1, 2, 3 // 第二个三角形
};

2> 创建索引缓冲对象:

ussigned int EBO;
glGenBuffers(1, &EBO);

3> 绑定EBO然后用将索引复制到缓冲中

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

4> 用glDrawElements来替换glDrawArrays函数,来指明我们从索引缓冲渲染。

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

第一个参数指定了我们绘制的模式;
第二个参数是我们打算绘制顶点的个数,这里填6;
第三个参数是索引的类型;
最后一个参数里我们指定EBO中的偏移量。

如果整套流程下来(使用VAO、VBO、EBO)整个三角形的位置代码会是这样:

#include <iostream>
#include <glad/glad.h> 
#include <GLFW/glfw3.h>

#include "config.h"
using namespace std;

const char *strvertexShaderSource = "#version 330 core
"
    "layout (location = 0) in vec3 aPos;
"
    "void main()
"
    "{
"
    "    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
"
    "}";

const char *strfragmentShaderSource = "#version 330 core
"
    "out vec4 FragColor;
"
    "void main()
"
    "{
"
    "   FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
"
    "}
";

int main()
{
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);

    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

    GLFWwindow *window = glfwCreateWindow(WIND_WIGHT, WIND_HEIGHT, "window", NULL, NULL);
    if (window == NULL)
    {
        std::cout << "create window failed!" << endl;
        glfwTerminate();
        return -1;
    }

    glfwMakeContextCurrent(window);

    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "init glad failed!" << endl;
        return -1;
    }

    // 创建一个着色器,着色器类型为 GL_VERTEX_SHADER(顶点着色器)
    unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
    // 把着色器源码附加到着色器对象
    glShaderSource(vertexShader, 1, &strvertexShaderSource, NULL);
    // 编译着色器
    glCompileShader(vertexShader);

    int success;
    char infoLog[512];
    // 判断编译结果并打印出错误
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
        std:cout << "VERTEX SHADER  COMPILATION_FAILED" << infoLog << std::endl;
    }

    // 创建一个着色器,着色器类型为 GL_FRAGMENT_SHADER (片段着色器)
    unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &strfragmentShaderSource, NULL);
    glCompileShader(fragmentShader);

    // 判断编译结果并打印出错误
    glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
        std::cout << "FRAGMENT SHADER COMPLATION_FAILED" << infoLog << std::endl;
    }

    // 链接着色器对象
    int shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);

    // 判断链接结果并打印出错误
    if (!success)
    {
        glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
        std::cout << "LINK SHADER FAILED" << infoLog << std::endl;
    }

    // 链接成功后就可以将shader删除
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);

    // 将顶点数据传入顶点着色器
    // 定义一个顶点数组
    float vertices[] = {
        0.5f, 0.5f, 0.0f,    // 右上角
        0.5f, -0.5f, 0.0f,   // 右下角
        -0.5f, -0.5f, 0.0f,  // 左下角
        -0.5f, 0.5f, 0.0f    // 左上角 
    };

    // 定义一个索引数组
    unsigned int indices[] = {
        0, 1, 3,
        1, 2, 3
    };

    // 创建一个VAO对象,一个VBO对象,一个EBO对象
    unsigned int VAO, VBO, EBO;
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);
    glGenBuffers(1, &EBO);

    // 绑定VAO对象    
    glBindVertexArray(VAO);

    // 将VBO复制到GL缓冲区中供GL使用
    glBindBuffer(GL_ARRAY_BUFFER, VBO);

    // 复制顶点数组到缓冲中供OpenGL使用
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    // 告诉GL如何去解释顶点缓冲对象(VBO)
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);

    // 将EBO复制到GL缓冲区中供GL使用
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);

    // 将顶点索引复制到缓冲中
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    // 告诉GL启用顶点属性
    glEnableVertexAttribArray(0);

    // 解除上下文绑定VAO,这样其他VAO调用不会意外地修改此VAO(通常情况下是不必要的)
    // glBindVertexArray(0);

    while (!glfwWindowShouldClose(window))
    {
        glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        // 使用链接好的shader
        glUseProgram(shaderProgram);
        
        // 绑定VAO对象(如果有操作解绑了VAO对象)
        // glBindVertexArray(VAO);
        
        // 指明从索引缓冲渲染
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    // GC
    glDeleteVertexArrays(1, &VAO);
    glDeleteBuffers(1, &VBO);
    glDeleteBuffers(1, &EBO);
    glDeleteProgram(shaderProgram);

    glfwTerminate();

    return 0;
}
原文地址:https://www.cnblogs.com/kpxy/p/13593975.html