Advanced OpenGL(4) - frame buffers

흑빡·2026년 6월 22일

그래픽스

목록 보기
39/50
post-thumbnail

frame buffer

지금까지 learnOpenGL을 공부하면서
Color, Depth, Stencil buffer를 알아봤음

매 프레임마다 buffer를 clear해주고 머시기 하고 ㅇㅇ

이런 프레임별로 화면에 띄울 fragment를 결정짓는 버퍼를 뭉뚱그려서 스크린버퍼 라고 부름
Color buffer, Depth buffer, Stencil buffer가 스크린 버퍼들인거지비 ㅇㅇ

그리고 이러한 버퍼들이 GPU 메모리에 하나로 모이면,
그 버퍼들의 집합을 frame buffer라고 부르는거임

LearnOpenGL(1) - 창 띄우기
여기에서 glfwSwapBuffers라는걸 사용하는걸 볼 수 있음

이게 위에서 설명한 프레임 버퍼를 프레임단위로 swap하는거임

이렇게 glfw의 기본 프레임버퍼만 사용 할 수도 있지만

우리가 직접 추가적으로 프레임 버퍼를 만들어서
거울에 따로 비치는 장면을 만든다던지,
원하는 특수효과(포스트 프로세싱)를 넣을 수 잇음

프레임 버퍼 사용하기

binding해서 쓰던것처럼 쓰면 됨

uint32_t fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);

아주 easy~~
Bind할때 bind 가능한 버퍼종류가 있긴함

  • GL_FRAMEBUFFER : 읽기, 쓰기 모두 가능한 frame buffer
  • GL_READ_FRAMEBUFFER : 읽기만 가능한 frame buffer
  • GL_FRAW_FRAMEBUFFER : 쓰기만 가능한 frame buffer

보통은 읽기, 쓰기 모두 가능한 프레임 버퍼를 쓰긴함ㅇㅇ
그리고 프레임 버퍼를 저상태로 쓸수있는건 아님
몇가지 조건이 만족되어야 그때부터 커스텀 프레임 버퍼를 쓸 수 있음

  1. 최소 1개 이상의 버퍼를 연결해야함 (color, depth, stencil)
  2. 최소 1개 이상의 색상을 결정짓는 요소가 연결되어 있어야함
    • e.g. color buffer, 텍스쳐 등
  3. 프레임 버퍼와 연결된 모든 attachments는 메모리에 할당되어있어야함
    • 텍스쳐가 메모리에 올라가있던지, color buffer가 메모리에 올라간 상태이던지 ㅇㅇ
  4. 연결된 버퍼는 모두 같은 수의 샘플을 사용해야함(안티앨리어싱 샘플링)

이게 조건이 모두 충족되면 이제 커스텀 프레임 버퍼를 사용할 수 있는거지비

그럼 사용가능한지 판단을 어떻게 하냐??

glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;

이렇게 COMPLETE가 반환되면 사용할 수 있다는거지비 ㅇㅇ

주의사항

  1. 기본 프레임 버퍼 렌더링이 안됨!!

커스텀 프레임 버퍼를 바인딩하면
이후 모든 attachments들은 커스텀 버퍼에게 연결되게 됨
그래서 기본 프레임 버퍼는 렌더링 되지 않음

따라서 기본 프레임 버퍼를 렌더링하려고 하면

glBindFramebuffer(GL_FRAMEBUFFER, 0);

를 해주어야함

  1. 모든 작업이 끝난 후에는 반드시 메모리 해제!
glDeleteFramebuffers(1, &fbo);

무조건무조건!

1. Texture Attachment

uint32_t texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

texture를 만들고 GL_TEXTURE_2D에 바인딩한 후,

glTexImage2D를 이용해 texture를 묶어줄건데
텍스쳐의 크기는 보통 화면크기(거울과 같으면 거울의 크기)를 사용하고,
texture data는 NULL을 사용함

왜냐면 지금당장 texture를 넣을게 아니고,
화면에 그릴 프레임이기때문에, 렌더링단계에서 텍스쳐(화면)이 채워질거임ㅇㅇ

더 작은 화면에 그리고 싶으면
프레임 버퍼에 그려지기 전에(렌더링)
원하는 텍스쳐(화면)크기에 맞춰 glViewPort를 호출해줘야함!

그리고 마찬가지로 texture parameter의 WRAP, MIPMAP모두 필요없음
그냥 축소, 확대될때 어떻게 필터링할건지만 지정해주자

이제 프레임버퍼의 필수조건인 색상 부착물(텍스쳐)을 프레임버퍼에 바인딩해주면 됨

uint32_t texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

uint32_t fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);

glFramebufferTexture2D의 파라미터는 다음과 같음

  • 첫번째 인자 : target, 사용하고자 하는 프레임 버퍼 종류
  • 두번째 인자 : attchment, 붙이려고하는 텍스쳐를 어느 프레임버퍼의 슬롯(color, depth, stencil)에 붙이려고하는지 지정
    • GL_COLOR_ATTACHMENT?, GL_DEPTH_ATTACHMENT, GL_STENCIL_ATTACHMENT, GL_DEPTH_STENCIL_ATTACHMENT
  • 세번째 인자 : textarget, 붙이고자 하는 텍스쳐의 유형
  • 마지막 인자 : mipmap, 대게는 0

텍스쳐 연결 예시

각 attachment마다 코드가 어떻게 달라지는지 보여줌

GL_COLOR_ATTACHMENT

GLuint colorTexture;
glGenTextures(1, &colorTexture);
glBindTexture(GL_TEXTURE_2D, colorTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

// 프레임버퍼에 텍스처 연결
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexture, 0);

GL_DEPTH_ATTACHMENT

GLuint depthTexture;
glGenTextures(1, &depthTexture);
glBindTexture(GL_TEXTURE_2D, depthTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 800, 600, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

// 프레임버퍼에 깊이 텍스처 연결
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTexture, 0);

GL_STENCIL_ATTACHMENT

GLuint stencilTexture;
glGenTextures(1, &stencilTexture);
glBindTexture(GL_TEXTURE_2D, stencilTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_STENCIL_INDEX8, 800, 600, 0, GL_STENCIL_INDEX, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

// 프레임버퍼에 스텐실 텍스처 연결
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, GL_TEXTURE_2D, stencilTexture, 0);

GL_DEPTH_STENCIL_ATTACHMENT

GLuint depthStencilTexture;
glGenTextures(1, &depthStencilTexture);
glBindTexture(GL_TEXTURE_2D, depthStencilTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

// 프레임버퍼에 깊이-스텐실 결합 텍스처 연결
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, depthStencilTexture, 0);

중요한건 glTexImage2D에서 3번째 인자인 internal format이 바뀌는걸 확인할수있도록

텍스쳐 종류internal formatformat
GL_COLOR_ATTACHMENTGL_RGB or something RGB relatedGL_RGB or something RGB related
GL_DEPTH_ATTACHMENTGL_DEPTH_COMPONENTGL_DEPTH_COMPONENT
GL_STENCIL_ATTACHMENTGL_DEPTH24_STENCIL8GL_STENCIL_INDEX
GL_DEPTH_STENCIL_ATTACHMENTGL_DEPTH24_STENCIL8GL_DEPTH_STENCIL

2. Renderbuffer Attachment

gl이 도입한 개념임

위의 texture attachment는 gpu에서 읽기,쓰기 모두 가능한 텍스쳐(color, depth, stencil)에 사용하는거임
프레임버퍼에 저장된 텍스쳐를 glsl에서 texture()를 이용해 불러와서 쓸수있다는게 읽기 가능하다는거임

반면 지금 설명할 render buffer object는 주로 쓰기만 가능한 객체임

읽기가 필요없는 객체??
뭐지 그게?

자자 바로 Advanced OpenGL시리즈를 시작할때 설명한
depth, stencil buffer가 대표적인 읽기가 필요없는 객체임

depth, stencil buffer는 정말 중요한 자원임
물체의 앞뒤를 구분해주고, 필요한 부분만 잘라서 렌더링할 수 있도록 해주니깐 ㅇㅇ

근데 이 depth, stencil buffer를 꼭 읽기 가능해야만 할까?
사실 그렇지 않음
읽기가 없고 쓰기만 가능해도 바로 렌더링 결과에서 확인가능하고,
실제로 쓰기만 잘하면 되는 buffer임

따라서 이 render buffer object는 depth, stencil을 위해서만 사용됨
또, 쓰기가 엄청 빠름
사용자의 GPU에서 지원하는 자체적인 format으로 데이터가 저장이 됨
그래서 쓰기가 엄청 빠르고,
또, 자체적인 format을 이용하기때문에 copy하기도 용이함
따라서 프레임버퍼 교체를 해야할때, 속도가 어마무시한거지 ㅇㅇ

따라서 프레임버퍼에 render buffer object를 사용하면 성능 이점이 확실함

render buffer object도 읽기가 가능함

glReadPixel을 이용해서 프레임버퍼 전체에게 해당 픽셀의 데이터를 cpu로 읽어오도록 할 수 있는거임
그리고 이 방식은 texture attachment에서도 사용가능함

하지만, rbo는 gpu에서 바로 값을 읽어 수정할 수 없다는거임
그래서 glsl texture()이 불가능함

render buffer object 사용하기

uint32_t rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);

이렇게 buffer생성, 바인딩하는것처럼 사용하면 됨

그리고 위 texture attachment에서 GL_DEPTH_STENCIL을 이용하던것처럼

glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

이렇게 glRenderbufferStorage를 이용해 RenderBuffer에 사용할 형식을 지정해주고
glFramebufferRenderbuffer를 이용해 프레임버퍼에 rbo를 attach해주면 됨

정리. texture attachment vs render buffer object attachment

texture attachment는 gpu에서 읽기,쓰기 모두 가능한 텍스쳐(color, depth, stencil)에 사용함
프레임버퍼에 저장된 텍스쳐를 glsl에서 texture()를 이용해 불러와서 쓸수있다는게 읽기 가능함


render buffer object attachment는 gpu에서 쓰기만 가능한 버퍼임
gpu에서 데이터를 읽어 수정하는 glsl의 texture()이 불가능함


하지만 gpu의 값을 cpu로 읽어서 사용하는건
texture attachment와 render buffer object attachment 모두 가능함
glReadPixel을 이용해서 프레임버퍼 전체에게 특정 픽셀의 데이터를 cpu로 읽어온 후, 원하는 작업을 수행할 수 있음
예를들어 사용자의 클릭 위치의 픽셀을 찾아 이벤트 수행 등등...


즉, 기존프레임 버퍼는 한번 렌더링하면 수정하기가 힘들어짐
따라서 해당 화면 전체에 특수효과(블러, 머시기, 그레이스케일, 머시기)등을 입히고 싶다고 해도 쉽지않음...

따라서 커스텀 프레임 버퍼는 기존프레임 버퍼에서 하지 못하는
렌더링된 화면 전체를 픽셀마다 캡쳐해서 텍스쳐로 활용해
특수효과(포스트 프로세싱)을 할 수 있는거임 ㄷㄷㄷ

프레임버퍼로 렌더링하기

위에서 말한대로

기존 프레임버퍼에서의 fragment를
우리가 만들 커스텀 프레임버퍼로 복사를하고
그 위에 특수효과를 입혀볼거임

// 커스텀 프레임버퍼
// -------------------------
unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
// 텍스쳐 프레임버퍼
unsigned int textureColorbuffer;
glGenTextures(1, &textureColorbuffer);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureColorbuffer, 0);
// 뎁스/스텐실용 렌더 버퍼 오브젝트
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, SCR_WIDTH, SCR_HEIGHT);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
    
//커스텀 프레임버퍼 체킹
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
    cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);

이렇게 텍스쳐 프레임버퍼, 렌더 버퍼 오브젝트를 만들어서
프레임버퍼에 바인딩 시켜주고
제대로 바인딩 되었는지 확인 후 기존 프레임버퍼로 버퍼 바꿔주기!

커스텀 프레임버퍼 렌더링 요구사항

  1. 커스텀 프레임버퍼에 먼저 렌더링
  2. 기존 프레임버퍼로 버퍼 바꾸기
  3. 기존 프레임버퍼에 위에서 설명한 컬러 텍스쳐를 사용해 큰 사각형을 그리고 위에 색칠

이렇게 하는 이유가 있음

일단 간단하게 다시 설명하면

  1. 커스텀 프레임버퍼 렌더링
  2. 기존 프레임버퍼 변경
  3. 기존 프레임버퍼 위에 다시 그리기

가 됨

특히, 2번과정에서 포스트 프로세싱 효과를 커스텀 프레임버퍼에 주게되면,
그걸 그대로 기존 프레임 버퍼위에 덧씌우기만 하는거니까
여러 효과를 줄 수 있게 되는거임


먼저 기존 프레임버퍼용 vertex shader, fragment shader를 만들어주자

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;

out vec2 TexCoords;

void main()
{
    TexCoords = aTexCoords;
    gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
}  

//----------------------------------------

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D screenTexture;

void main()
{
    vec3 col = texture(screenTexture, TexCoords).rgb;
    FragColor = vec4(col, 1.0);
} 

그냥 간단함
위에서 사용 요구사항에서 설명했듯이
커스텀 프레임버퍼에 렌더링 된거를
그대로 화면을 가득 채우는 사각형 위에 그릴거임

이제 렌더링 코드를 보자

float quadVertices[] = 
{
    // positions   // texCoords
    -1.0f,  1.0f,  0.0f, 1.0f,
    -1.0f, -1.0f,  0.0f, 0.0f,
     1.0f, -1.0f,  1.0f, 0.0f,

    -1.0f,  1.0f,  0.0f, 1.0f,
     1.0f, -1.0f,  1.0f, 0.0f,
     1.0f,  1.0f,  1.0f, 1.0f
    };

while (!glfwWindowShouldClose(window))
{
	//.....
    
    // render
    // ------
    // 커스텀 프레임버퍼
    glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
    glEnable(GL_DEPTH_TEST);
    glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    shader.use();
    glm::mat4 model = glm::mat4(1.0f);
    glm::mat4 view = camera.GetViewMatrix();
    glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
    shader.setMat4("view", view);
    shader.setMat4("projection", projection);
    // cubes
    glBindVertexArray(cubeVAO);
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, cubeTexture);
    model = glm::translate(model, glm::vec3(-1.0f, 0.0f, -1.0f));
    shader.setMat4("model", model);
    glDrawArrays(GL_TRIANGLES, 0, 36);
    model = glm::mat4(1.0f);
    model = glm::translate(model, glm::vec3(2.0f, 0.0f, 0.0f));
    shader.setMat4("model", model);
    glDrawArrays(GL_TRIANGLES, 0, 36);

    //기존 프레임버퍼
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    glDisable(GL_DEPTH_TEST);
    glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    screenShader.use();
    glBindVertexArray(quadVAO);
    glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
    glDrawArrays(GL_TRIANGLES, 0, 6);


    // glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
    // -------------------------------------------------------------------------------
    glfwSwapBuffers(window);
    glfwPollEvents();
}

기존 프레임버퍼를 채울 삼각형 2개로 사각형을 만들어줌
이때 NDC를 이용해서 가득 채우면 됨

그럼 이런 화면이 렌더링됨

근데 뭐가 다른거지??

glPolygonMode를 이용해서 와이어프레임을 그려보면 티가남

화면의 오브젝트들이

와이어프레임으로 표시되지않고
화면 전체가 2개의 삼각형으로 표시되어있음

그니까 커스텀 프레임버퍼는 진짜 딱 그리고 빠진거지
그리고 화면에 표시되는건
기존 프레임버퍼로 복사된 커스텀 프레임버퍼에서 렌더링된 화면인거고ㅇㅇ

포스트 프로세싱 예시

뭐 여러 효과가 있음
블룸, 블러, 그레이스케일, 샤프닝 등등...

이 중 커널을 이용한걸 해보겠음

그래픽스 - 블러, 블룸
여기서 kernel에 대해 설명함ㅇㅇ

kernel은 간단하게 말하면
현재 픽셀이 중앙에 오는 행렬을 이용해서
주변 픽셀의 색상에 값을 더하고 곱하고 별짓을 하여 만드는 특수한 필터임

fragment shader에서 수행하면 끝인거임ㅇㅇ

sharpen

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D screenTexture;

const float offset = 1.0 / 300.0;

void main()
{
    vec2 offsets[9] = vec2[](
            vec2(-offset,  offset), // top-left
            vec2( 0.0f,    offset), // top-center
            vec2( offset,  offset), // top-right
            vec2(-offset,  0.0f),   // center-left
            vec2( 0.0f,    0.0f),   // center-center
            vec2( offset,  0.0f),   // center-right
            vec2(-offset, -offset), // bottom-left
            vec2( 0.0f,   -offset), // bottom-center
            vec2( offset, -offset)  // bottom-right    
    );

    float kernel[9] = float[](
            -1, -1, -1,
            -1,  9, -1,
            -1, -1, -1
    );

    vec3 sampleTex[9];
    for(int i = 0; i < 9; i++)
    {
        sampleTex[i] = vec3(texture(screenTexture, TexCoords.st + offsets[i]));
    }
    vec3 col = vec3(0.0);
    for(int i = 0; i < 9; i++)
    col += sampleTex[i] * kernel[i];

    FragColor = vec4(col, 1.0);
}

기본 예시로 sharpen 커널을 이용한거임

offset은 간단하게 수행되는 색상계산의 세기로
값이 작아지면 더 좁은 범위의 픽셀만 계산 -> 필터 강도 심해짐
값이 커지면 더 넓은 범위 픽셀 계산 -> 필터 강도 약해짐

offset : 1/3000offset : 1/300

이런 결과가 나옴

blur

float kernel[9] = float[](
    1.0 / 16, 2.0 / 16, 1.0 / 16,
    2.0 / 16, 4.0 / 16, 2.0 / 16,
    1.0 / 16, 2.0 / 16, 1.0 / 16  
);

위 코드에서 kernel만 수정해주면 됨

극단적으로 offset을 1/30으로만 했을때

이렇게 심한 블러링이 됨

edge detecting

float kernel[9] = float[](
        1, 1, 1, 
        1, -8, 1,
        1, 1, 1
);

ㅇㅇ끄읕~~

profile
그래픽스 하는 퍼그

0개의 댓글