본문 바로가기
DirectX

[DirectX 11] 스터디 4일 텍스쳐

by Minok_nok 2020. 2. 1.

Direct x 11 스터디 4주 차

 

텍스쳐

 

참고 블로그

https://copynull.tistory.com/243?category=649932

 

[DirectX11] Tutorial 5 - 텍스쳐

Tutorial 5 - 텍스쳐 원문 : http://www.rastertek.com/dx11s2tut05.html 이 튜토리얼은 DirectX 11에서 텍스처를 사용하는 방법을 설명합니다. 텍스처를 사용하면 도형 표면에 사진과 다른 이미지를 적용하여 더..

copynull.tistory.com

이 블로그를 보며 작성한 글입니다.

이 게시글을 기준으로 보는 걸 추천드립니다.

 

 

개요

저번에는 단색 또는 버텍스의 색이 섞인 모델링을 출력해보았다면, 이번에는 이미지를 입힌 모델을 출력해봅시다.

 

텍셀 좌표계

텍스쳐를 입힐때 텍셀 좌표계라는 좌표계를 사용합니다.

저런 사각형 메쉬가 있다면 좌측 상단이 (0,0) 우측 하단이 (1,1)인 좌표계입니다.

 

UV좌표계라고도 불립니다.

 

https://kblog.popekim.com/2011/12/03-part-1.html

 

[포프의 쉐이더 입문강좌] 03. 텍스처매핑 Part 1

게임 프로그래머 김포프의 블로그

kblog.popekim.com

한 물체의 UV의 설정값에 따른 표현입니다.

UV가 (1,1)보다 넓게 지정이 되어있다면 다수의 텍스처가 출력이 되고, (1,1)보다 낮게 지정이 되어있다면 중간이 잘려보입니다.

 

프레임 워크 업데이트

ModelClass에 TextureClass가 캡슐화가 되어있습니다.

 

Texture.vs

 

새로운 버텍스 셰이더를 짜 봅시다.

맨 처음에는 버텍스의 색상만 출력했지만, 이번에는 텍스쳐까지 입힌 다음 출력하는 셰이더를 만들어봅시다.

 

////////////////////////////////////////////////////////////////////////////////
// Filename: texture.vs
////////////////////////////////////////////////////////////////////////////////


/////////////
// GLOBALS //
/////////////
cbuffer MatrixBuffer
{
  matrix worldMatrix;
  matrix viewMatrix;
  matrix projectionMatrix;
};


//////////////
// TYPEDEFS //
//////////////
struct VertexInputType
{
    float4 position : POSITION;
    float2 tex : TEXCOORD0;
};

struct PixelInputType
{
    float4 position : SV_POSITION;
    float2 tex : TEXCOORD0;
};


////////////////////////////////////////////////////////////////////////////////
// Vertex Shader
////////////////////////////////////////////////////////////////////////////////
PixelInputType TextureVertexShader(VertexInputType input)
{
    PixelInputType output;
    

// 적절한 행렬 계산을 위해 위치 벡터를 4 단위로 변경합니다.
    input.position.w = 1.0f;

// 월드, 뷰 및 투영 행렬에 대한 정점의 위치를 ​​계산합니다.
    output.position = mul(input.position, worldMatrix);
    output.position = mul(output.position, viewMatrix);
    output.position = mul(output.position, projectionMatrix);
    
// 픽셀 쉐이더의 텍스처 좌표를 저장한다.
    output.tex = input.tex;
    
    return output;
}

 

생각 보다는 많이 바뀌진 않았습니다.

 

제일 큰 변화는  Vertex와 Pixel InputType구조체에 float4의 color값이 아닌 float2의 tex값이 들어가 있다는겁니다.

 

이 tex의 시맨틱이 TEXCOORD0인데, 이는 텍스쳐의 UV 좌표값을 뜻하는 뜻합니다.

텍스쳐를 여러개 넣을 수 있기 때문에 TEXCOORD(0~n)형식으로 시맨틱을 정의합니다. 

 

그리고 또 다른 변화는 output.color=input.color였던 부분이 output.tex=input.tex가 되었습니다.

 

tex의 값은 넣을 텍스쳐의 UV좌표입니다.

 

Texture.ps

/////////////
// GLOBALS //
/////////////
Texture2D shaderTexture;
SamplerState SampleType;


//////////////
// TYPEDEFS //
//////////////
struct PixelInputType
{
    float4 position : SV_POSITION;
    float2 tex : TEXCOORD0;
};


////////////////////////////////////////////////////////////////////////////////
// Pixel Shader
////////////////////////////////////////////////////////////////////////////////
float4 TexturePixelShader(PixelInputType input) : SV_TARGET
{
	float4 textureColor;

    // 이 텍스처 좌표 위치에서 샘플러를 사용하여 텍스처에서 픽셀 색상을 샘플링합니다.
    textureColor = shaderTexture.Sample(SampleType, input.tex);

    return textureColor;
}


 

픽셀 셰이더의 가장 큰 변화점이라면

전역변수의 생성과 새로운 함수인 Sample입니다.

 

textrue.ps: texture.ps의 전역변수

 

첫번째 전역변수는 Texture2D타입의 shaderTexture입니다.

Texture2D는 텍스쳐 그자체를 나타냅니다.

 

이를 이용하여 VertexShader에서 받은 UV좌표값을 바탕으로 텍스쳐를 씌웁니다.

 

두번째 전역변수는 SamplerState SampleType입니다.

SamplerState는 도형에 셰이딩이 이루어질 때 텍스쳐의 픽셀이 어떻게 씌여질지 수정할 수 있게 해줍니다.

 

예를 들어 멀리 있는 도형이 화면의 8픽셀정도만 차지한다고 한다면, 이 샘플러 상태를 사용하여 원래 텍스쳐의 어떤 픽셀을 사용할지 결정합니다.

 

멀리 있는 도형의 텍스쳐의 원본이 256X256 픽셀의 크기일수도 있으니 이런 샘플러 설정은 필요합니다.

 

이 샘플러 상태를 TextureShaderClass에 만들고 연결하여 사용할 수 있습니다.

 

texture.ps: Sample함수

textureColor = shaderTexture.Sample(SampleType, input.tex)

float4타입의 textureColor변수를 Texture2D의 Sample함수를 사용하여 적용시켜주었습니다.

input의 UV좌표를 기준으로 텍스쳐를 샘플링할때 SampleType에 정해진 샘플링 환경으로 추출하여 색상을 반환합니다.

 

textrue.ps: 왜 픽셀셰이더에 텍스쳐를 입힐까?

 

이에 대한 답을 도출할려면 버텍스 셰이더와 픽셀 셰이더의 정의를 한번더 알아가야합니다.

아래 이미지는 렌더링 파이프라인을 간략히 생략하여 만들어놓은 이미지입니다.

https://kblog.popekim.com/2011/12/03-part-1.html

 

[포프의 쉐이더 입문강좌] 03. 텍스처매핑 Part 1

게임 프로그래머 김포프의 블로그

kblog.popekim.com

 

위 이미지 처럼 정점 셰이더는 단순히 정점 데이터를 받아 정점의 위치를 변환시키는 작업을 주로합니다.

이런 정점들 하나하나로 텍스쳐를 입힐만한 면을 생성하지 못하기 때문에 레스터라이저를 통해 면을 만든 뒤, 그렇게 만들어진 면을 바탕으로 픽셀셰이더에서 텍스쳐를 입힙니다.




TextureClass 초기화

 

정점 및 인덱스 버퍼를 구성했던 ModelClass의 초기화 함수에서 새롭게 텍스쳐까지 로드하는 함수가 생겼습니다.

 

bool ModelClass::Initialize(ID3D11Device* device, ID3D11DeviceContext* deviceContext, char* textureFilename)
{
  // 정점 및 인덱스 버퍼를 초기화합니다.
  if (!InitializeBuffers(device))
  {
	  return false;
  }

  // 이 모델의 텍스처를 로드합니다.
  return LoadTexture(device, deviceContext, textureFilename);
}

bool ModelClass::LoadTexture(ID3D11Device* device, ID3D11DeviceContext* deviceContext, char* filename)
{
  // 텍스처 오브젝트를 생성한다.
  m_Texture = new TextureClass;
  if (!m_Texture)
  {
 	 return false;
  }

  // 텍스처 오브젝트를 초기화한다.
  return m_Texture->Initialize(device, deviceContext, filename);
}

ModelClass안에 TextureClass를 캡슐화 해준 모습입니다.

 

 

bool TextureClass::Initialize(ID3D11Device* device, ID3D11DeviceContext* deviceContext, char* filename)
{
  int width = 0;
  int height = 0;

  // targa 이미지 데이터를 메모리에 로드합니다.
  if (!LoadTarga(filename, height, width))
  {
  return false;
  }
  .

  .

  .

TextureClass의 초기화 함수입니다.

맨 처음에 텍스쳐로 쓸 targa이미지 데이터를 메모리에 로드하는 함수를 실행합니다.

 

이 함수에 대해 요약하자면 targa이미지를 불러와 unsigned char* 타입의  m_targaData변수에게 targa의 정보가 담긴 배열을 대입해 주는 함수입니다.

 

TextureClass 초기화: 텍스처 구조체 생성

 

텍스처 구조체를 만들어 버텍스 셰이더에 보내주어야 합니다.

//텍스처의 구조체를 설정합니다.
D3D11_TEXTURE2D_DESC textureDesc;
textureDesc.Height = height;
textureDesc.Width = width;
textureDesc.MipLevels = 0;
textureDesc.ArraySize = 1;
textureDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
textureDesc.SampleDesc.Count = 1;
textureDesc.SampleDesc.Quality = 0;
textureDesc.Usage = D3D11_USAGE_DEFAULT;
textureDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET;
textureDesc.CPUAccessFlags = 0;
textureDesc.MiscFlags = D3D11_RESOURCE_MISC_GENERATE_MIPS;

// 빈 텍스처를 생성합니다.
HRESULT hResult = device->CreateTexture2D(&textureDesc, NULL, &m_texture);
if (FAILED(hResult))
{
return false;
}

 

텍스쳐 셰이더에게 tga파일을 보내기 위해 D3D에서 미리 텍스처 구조체를 이용하여 텍스처를 만들어 줍니다.

 

D3D11_TEXTURE2D_DESC 구조체를 만든 뒤 구조체의 속성을 대입해주었습니다.

LoadTarga함수로 불러놓은 텍스처의 높이 및 너비를 대입해주고,

포맷과 추가설정을 한 뒤 D3D11Device의 CreateTexture2D함수로 설정만 있는 ID3D11Texture2D형태의 빈 텍스쳐를 생성해줍니다.

 

//  targa 이미지 데이터의 너비 사이즈를 설정합니다.
UINT rowPitch = (width * 4) * sizeof(unsigned char);

// targa 이미지 데이터를 텍스처에 복사합니다.
deviceContext->UpdateSubresource(m_texture, 0, NULL, m_targaData, rowPitch, 0);

targa파일의 데이터를 배열로 추출한것을 바탕으로 ID3D11Textrue2D의 추가 정보를 복사해줍니다.

 

UpdateSubresource함수의 매개변수에는 ID3D11Resource를 상속받은 클래스라면 매개변수로 사용할 수 있습니다.

ID3D11Textrue2D도 ID3D11Resource를 상속받았기 때문에 사용가능합니다.

 

https://grandstayner.tistory.com/entry/DirectX-11-UpdateSubResource-Map-Unmap-CopyResource-CopySubresourceRegion%EC%9D%98-%ED%8C%81

 

DirectX11 - UpdateSubResource(), Map() ~ Unmap(), CopyResource(), CopySubresourceRegion()의 팁

DirectX 11에서는 Resource에 데이터를 넣는 함수는 4개가 존재한다. UpdateSubresource(), Map() ~ Unmap(), CopyResource(), CopySubresourceRegion() 이 함수들은 자주 사용되는 만큼 어느 정도는 알고 사용해..

grandstayner.tistory.com

 

TextureClass 초기화: 셰이더 리소스 뷰 생성

 

DirectX에서는 셰이더 리소스 뷰를 사용해야 로드된 텍스쳐가 셰이더에 직접 접근할수 있도록해줍니다.

 

// 셰이더 리소스 뷰 구조체를 설정합니다.
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
srvDesc.Format = textureDesc.Format;
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MostDetailedMip = 0;
srvDesc.Texture2D.MipLevels = -1;

// 텍스처의 셰이더 리소스 뷰를 만듭니다.
hResult = device->CreateShaderResourceView(m_texture, &srvDesc, &m_textureView);
if (FAILED(hResult))
{
	return false;
}

// 이 텍스처에 대해 밉맵을 생성합니다.
deviceContext->GenerateMips(m_textureView);

 

셰이더 리소스 뷰의 포맷을 생성한 텍스쳐의 포맷과 같도록 해준뒤 CreateShaderResourceView를 사용하여 텍스쳐와 관련된 셰이더 리소스 뷰를 만들어주었습니다

 

그 뒤 이 텍스처가 확대 또는 축소를 했을때 어떻게 변할지 미리 정해놓는 밉맵을 생성합니다.

https://tiredsleeper.tistory.com/11

 

[Graphics] MipMap(밉맵)

밉맵(MipMap) : 3D 그래픽스의 텍스쳐 매핑 분야에서 렌더링을 효율적으로 수행하기 위해 텍스쳐를 크기별로 미리 축소된 이미지들의 집합. Mip은 라틴어 Multum In Parvo 의 각각 머릿글자를 따온것이다. 이 Mult..

tiredsleeper.tistory.com

 

이렇게 해서 만들어진 텍스쳐의 정보를 받아오는 함수도 하나 만들어줍니다.

 

보통은 ID3D11Texture2D를 이 클래스에서 받아와 참조를 할것 같지만

중개자 역할을 해주는 ID3D11ShaderResourceView를 받아와 텍스처 정보에 대해 접근을 합니다.

ID3D11ShaderResourceView* TextureClass::GetTexture()
{
	return m_textureView;
}

 

ModelClass 변경

VertexShader에서 사용할 변수의 타입이 바뀌었기 때문에 ModelClass의 정점 정보를 갱신해주어야합니다.

struct VertexType
{
  XMFLOAT3 position;
  XMFLOAT2 texture;
};

 

새로 변경된 VertexType구조체입니다.

texture변수는 이 버텍스가 UV의 어떤 부분을 담당하는지 지정해줍니다.

// 정점 배열에 값을 설정합니다.
vertices[0].position = XMFLOAT3(-1.0f, -1.0f, 0.0f);  // Bottom left.
vertices[0].texture = XMFLOAT2(0.0f, 1.0f);

vertices[1].position = XMFLOAT3(0.0f, 1.0f, 0.0f);  // Top Middle.
vertices[1].texture = XMFLOAT2(0.5f, 0.0f);

vertices[2].position = XMFLOAT3(1.0f, -1.0f, 0.0f);  // Bottom right.
vertices[2].texture = XMFLOAT2(1.0f, 1.0f);

 

 

 

이런모습으로 초기화가 되었습니다.

 

TextureShaderClass 초기화

 

저번에 단순히 단색을 출력할때는 ColorShaderClass를 사용했습니다.

이번에는 Texture을 매핑한 모습을 출력할거기 때문에 TextrueShaderClass를 만들어서 사용해줍시다.

 

다른 초기화 부분은 ColorChaderClass와 똑같으니 차이가 있는 부분만 가져오겠습니다.

// 정점 입력 레이아웃 구조체를 설정합니다.
// 이 설정은 ModelClass와 셰이더의 VertexType 구조와 일치해야합니다.
D3D11_INPUT_ELEMENT_DESC polygonLayout[2];
polygonLayout[0].SemanticName = "POSITION";
polygonLayout[0].SemanticIndex = 0;
polygonLayout[0].Format = DXGI_FORMAT_R32G32B32_FLOAT;
polygonLayout[0].InputSlot = 0;
polygonLayout[0].AlignedByteOffset = 0;
polygonLayout[0].InputSlotClass = D3D11_INPUT_PER_VERTEX_DATA;
polygonLayout[0].InstanceDataStepRate = 0;

polygonLayout[1].SemanticName = "TEXCOORD";
polygonLayout[1].SemanticIndex = 0;
polygonLayout[1].Format = DXGI_FORMAT_R32G32_FLOAT;
polygonLayout[1].InputSlot = 0;
polygonLayout[1].AlignedByteOffset = D3D11_APPEND_ALIGNED_ELEMENT;
polygonLayout[1].InputSlotClass = D3D11_INPUT_PER_VERTEX_DATA;
polygonLayout[1].InstanceDataStepRate = 0;


POSITION은 똑같지만 TEXCOORD 시맨틱을 사용하는 요소가 COLOR를 대체했습니다.

이 TEXCOORD요소는 TEXCOORD(N)의 자리를 사용하게됩니다.

 

그 뒤 PixelShader에서 사용할 SamplerType을 만들어 줍니다.

// 텍스처 샘플러 상태 구조체를 생성 및 설정합니다.
D3D11_SAMPLER_DESC samplerDesc;
samplerDesc.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR;
//samplerDesc.Filter = D3D11_FILTER_MAXIMUM_ANISOTROPIC;
samplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.MipLODBias = 0.0f;
samplerDesc.MaxAnisotropy = 1;
samplerDesc.ComparisonFunc = D3D11_COMPARISON_ALWAYS;
samplerDesc.BorderColor[0] = 0;
samplerDesc.BorderColor[1] = 0;
samplerDesc.BorderColor[2] = 0;
samplerDesc.BorderColor[3] = 0;
samplerDesc.MinLOD = 0;
samplerDesc.MaxLOD = D3D11_FLOAT32_MAX;

// 텍스처 샘플러 상태를 만듭니다.
result = device->CreateSamplerState(&samplerDesc, &m_sampleState);
if (FAILED(result))
{
	return false;
}

 

samplerDesc.Filter부분이 유저에게 체감이 되는 부분인데, 이는 오브젝트가 작아지거나 커졌을때 어떤 효과를 주어 표현할건지 설정해줍니다.

 

그 뒤 구조체 설정을 마친뒤 ID3D11Device클래스에게 샘플러 상태도 저장시킵니다.

 

TextureShaderClass 렌더

 

TextureShaderClass도 ColorShaderClass와 마찬가지로

 

ModelClass가 그릴 물체의 버텍스와 인덱스 버퍼를 등록하면, TextureShaderClass에서 컴파일한 버텍스 셰이더와 픽셀 셰이더를 등록하여 DrawIndexed함수로 삼각형을 그려줍니다.

 

그 전에 버텍스 셰이더와 픽셀 셰이더가 사용할 전역변수및 상수 버퍼를 등록해주어야하는데 이 함수가 SetShaderParameters함수입니다.

 

그 함수에서 원래는 월드, 뷰, 투영행렬을 담은 상수버퍼만 넣어주었지만 이번에 픽셀 셰이더에서 처리할 텍스쳐도 넣어줍니다.

// 픽셀 셰이더에서 셰이더 텍스처 리소스를 설정합니다.
deviceContext->PSSetShaderResources(0, 1, &texture);

 

다음으로 호출할 함수는 SetShaderParameters함수입니다.

마지막 매개변수로 넘겨준 texture은 ModelClass안에 있던 TextureClass에서 생성한 ID3D11ShaderResourceView를 넣어줍니다.

 

void TextureShaderClass::RenderShader(ID3D11DeviceContext* deviceContext, int indexCount)
{
  // 정점 입력 레이아웃을 설정합니다.
  deviceContext->IASetInputLayout(m_layout);

  // 삼각형을 그릴 정점 셰이더와 픽셀 셰이더를 설정합니다.
  deviceContext->VSSetShader(m_vertexShader, NULL, 0);
  deviceContext->PSSetShader(m_pixelShader, NULL, 0);

  // 픽셀 쉐이더에서 샘플러 상태를 설정합니다.
  deviceContext->PSSetSamplers(0, 1, &m_sampleState);

  // 삼각형을 그립니다.
  deviceContext->DrawIndexed(indexCount, 0, 0);
}


위에서 방금 만들어준 샘플러는 Render함수를 돌릴때 PixelShader에게 보내줍니다.

 

결과

 

삼각형에 텍스쳐를 씌웠을 때 이런 모습으로 렌더링이 됩니다.

 

이 삼각형을 사각형으로 확장했을땐

삼각형이라 잘린 부분마저 렌더링이 되는 모습입니다.

// 정점 배열에 값을 설정합니다.
vertices[0].position = XMFLOAT3(-1.0f, -1.0f, 0.0f);  // Bottom left.
vertices[0].texture = XMFLOAT2(0.0f, 1.0f);

vertices[1].position = XMFLOAT3(1.0f, -1.0f, 0.0f);  // Bottom right.
vertices[1].texture = XMFLOAT2(1.0f, 1.0f);

vertices[2].position = XMFLOAT3(-1.0f, 1.0f, 0.0f);  // Top left.
vertices[2].texture = XMFLOAT2(0.0f, 0.0f);

vertices[3].position = XMFLOAT3(1.0f, 1.0f, 0.0f);  // Top right.
vertices[3].texture = XMFLOAT2(1.0f, 0.0f);


// 인덱스 배열에 값을 설정합니다.
indices[0] = 0;  // Bottom left.
indices[1] = 2;  // Top middle.
indices[2] = 1;  // Bottom right.
indices[3] = 1;  // Bottom left.
indices[4] = 2;  // Top middle.
indices[5] = 3;  // Bottom right.

 

버텍스가 갱신되면서 UV값도 그에 맞춰 정해주어야 합니다.

 

잘못 넣으면 이렇게 텍스쳐가 늘어나거나 깨집니다.



오늘은 헷갈리지 않게 프레임 워크정리를 해 봅시다.



WinMain

프로그램의 진입점이다.

SystemClass를 만들어 SystemClass가 시작되도록 만든다.

SystemClass

Window의 함수를 사용하여, 화면구성 및 여러 핸들을 관리하며, 프레임마다 입력을 받거나 특정한 행동을 취하게 합니다.

InputClass

간단히 어떤 키가 입력되어있는지 판단해줍니다.

GraphicsClass

렌더에 필요한 클래스들을 캡슐화를 하고 있습니다.

이런 클래스를 다 같이 초기화 하고, 렌더를 할때는 순서에 맞게 렌더를 해줍니다.

D3DClass

DirectX와 GPU끼리 상호작용을 할수있는 객체들을 만듭니다.


이런 객체들은 모니터와 프로그램의 상호작용, 스왑체인, 렌더링에 관련된 ID3D11Device와 ID3D11DeviceContext를 생성해줍니다.

 

그 외에도 깊이, 스텐실뷰 생성과 투영행렬 생성까지 해줍니다.


렌더시 대표적으로 백버퍼를 먼저 지워주고 최종적으로 백 버퍼에  버텍스가 모두 출력이 됐다면 스왑체인을 해줍니다.

CameraClass

뷰 행렬을 만들어주는 클래스입니다.

카메라를 이동 또는 회전을 시킬수 있습니다.

ModelClass

저희가 만들 모델의 정점 정보, 인덱스 정보를 가지고 있습니다.

이 정보를 보낼 Buffer을 만들어 가지고있습니다.


렌더시 이 버퍼를 등록시킵니다.


이번에 새롭게 이 모델의 텍스처 정보까지 가지게 되었습니다.

TextureClass

모델에 적용시킬 텍스처의 정보를 가지고있습니다.


바이너리 모드로 tga파일을 불러온 뒤, 그 데이터를 이용해 만든

ID3D11Texture2D를 PixelShader에게 보내주기 위해 ID3D11ShaderResourceView를 만들어줍니다.

TextureShaderClass

버텍스와 픽셀셰이더를 초기화 시켜준 뒤 Device에 등록시켜줍니다.


그런 뒤에 셰이더에 행렬, 텍스쳐 등 여러 파라미터를 넣어준 뒤 마지막으로 ModelClass에서 올려놓은 버텍스, 인덱스버퍼를 사용하여 모델을 그리는 역할을합니다.


여기서 정점 입력 구조까지 작성하기때문에 ModelClass와 Shader와의 상호작용을 잘 해야하는 부분입니다.

 

후기

텍스쳐 이후에는 조명을 할 예정입니다.

스터디를 하고 몇주 뒤에 게시글을 올리는데, 요즘은 과제로 결과물을 만드는 스터디를 하고있습니다.

 

댓글