결국 이 방식으로 파고 들어가면, k번째에 생성되는 조합은 어떤 경우를 갖는지 알아낼 수 있습니다. { 1, 2, 3 } 수열에서 5번째로 오는 값을 찾는 과정을 아래의 함수로 살펴보겠습니다.
long long factorial(int _Num)
{
long long Result = 1;
for (int i = 1; i <= _Num; ++i)
{
Result = Result * i;
}
return Result;
}
// ...
--k;
for (int i = 0; i < n; ++i)
{
long long Value = factorial(n - 1 - i);
long long Index = k / Value;
k %= Value;
if (Index >= Numbers.size())
{
Index = Numbers.size() - 1;
}
answer.push_back(Numbers[Index]);
Numbers.erase(Numbers.begin() + Index);
}
1. --k : 인덱스는 0부터 계산하기 때문에, 1을 빼서 활용
2. n = 3일 때, 가능한 순열 수는 3! = 6 입니다.
3. i = 0 일 경우
- Value는 (3-1-0)! = 2! = 2
- Index는 4 / 2 = 2
- k %= Value는 0
- 이로써 3이 선택되고, 1. 2가 남습니다.
4. i = 1 일 경우
- Value는 (3-1-1)! = 1! = 1
- Index는 0 / 1 = 0
- k %= Value는 0
- 이로써 1이 선택되고, 2가 남습니다.
4. i = 2 일 경우
- Value는 (3-1-2)! = 0! = 1
- Index는 0 / 1 = 0
- k %= Value는 0
- 이로써 2가 선택되고, 종료됩니다.
결론은 전체 경우의 수 중 5번째의 경우로 3을 선택하고, 그 안에서 다시 첫 번째로 와야하는 경우로 1을 선택하고, 이 과정을 반복하는 것입니다.
#include <string>
#include <vector>
using namespace std;
long long factorial(int _Num)
{
long long Result = 1;
for (int i = 1; i <= _Num; ++i)
{
Result = Result * i;
}
return Result;
}
vector<int> solution(int n, long long k)
{
vector<int> answer;
vector<long long> Numbers;
for (int i = 1; i <= n; ++i)
{
Numbers.push_back(i);
}
--k;
for (int i = 0; i < n; ++i)
{
long long Value = factorial(n - 1 - i);
long long Index = k / Value;
k %= Value;
if (Index >= Numbers.size())
{
Index = Numbers.size() - 1;
}
answer.push_back(Numbers[Index]);
Numbers.erase(Numbers.begin() + Index);
}
return answer;
}
크게 특별한 조건이 없는 그래프 탐색 문제입니다. 우선 방문 설정을 위한 Visited 배열을 하나 만들어주고, 방문했거나 X인 부분은 N, 방문 가능한 곳은 Y로 기록해둡니다.
이후 배열을 순회하면서 Y인 부분이 있다면 해당 지점을 기준으로 BFS()를 실시합니다. BFS를 모두 돌면 연결된 노드들에 대해 Add 값이 산출됩니다. 물론 방문한 노드들은 N으로 기록해주기 때문에 이후 배열 순회에서 해당 지점을 기준으로 BFS()가 실시되지 않을 것입니다.
이 과정을 배열의 모든 요소에 대해 실시하면 무인도(연결된 노드들) 갯수 만큼 답이 나옵니다. 마지막에 오름차순으로 sort()후 리턴해줍니다. 물론 아무것도 방문할 수 없는 상태라면 answer가 empty() 상태일 것입니다. 이 때만 -1을 push_back() 하여 리턴해줍니다.
#include <string>
#include <vector>
#include <queue>
#include <algorithm>
int SearchY[4] = { -1, 0, 1, 0 };
int SearchX[4] = { 0, 1, 0, -1 };
using namespace std;
vector<string> Visited;
void BFS(vector<string>& maps, int _StartY, int _StartX, int& _Add)
{
queue<pair<int, int>> Que;
Que.push(make_pair(_StartY, _StartX));
while (!Que.empty())
{
int CurY = Que.front().first;
int CurX = Que.front().second;
Que.pop();
for (int i = 0; i < 4; i++)
{
int TempY = CurY + SearchY[i];
int TempX = CurX + SearchX[i];
if (TempY < 0 || TempY >= maps.size() || TempX < 0 || TempX >= maps[0].size())
{
continue;
}
if (maps[TempY][TempX] == 'X')
{
continue;
}
if (Visited[TempY][TempX] == 'N')
{
continue;
}
Visited[TempY][TempX] = 'N';
_Add += maps[TempY][TempX] - '0';
Que.push(make_pair(TempY, TempX));
}
}
}
// X = 바다
// 숫자 = 무인도
// 연결된 무인도들의 숫자 합은 최대 무인도에서 머무를 수있는 날
// 연결된 섬들에서 각각 몇일 씩 머물 수 있는지 return, 아무것도 없으면 -1 리턴, 오름차순 정렬
vector<int> solution(vector<string> maps)
{
vector<int> answer;
Visited.resize(maps.size(), "");
for (size_t i = 0; i < maps.size(); i++)
{
for (size_t j = 0; j < maps[i].size(); j++)
{
char Temp = maps[i][j];
if (Temp == 'X')
{
Visited[i] += 'N';
}
else
{
Visited[i] += 'Y';
}
}
}
for (size_t i = 0; i < maps.size(); i++)
{
for (size_t j = 0; j < maps[i].size(); j++)
{
if (Visited[i][j] == 'Y')
{
int Add = maps[i][j] - '0';
Visited[i][j] = 'N';
BFS(maps, i, j, Add);
answer.push_back(Add);
}
}
}
if (!answer.empty())
{
sort(answer.begin(), answer.end());
}
else
{
answer.push_back(-1);
}
return answer;
}
카카오 문제는 언제나 지문이 길지만, 막상 까보면 그냥 구현 문제이다. 지금까지 풀어왔던 카카오 문제와 유사한데, 정보가 긴 문자열(로그)로 주어지고, 그 로그의 정보를 해석하여 문제를 푸는 형식이다. 제한은 넉넉하다.
본인은 그냥 악보의 #을 따로 처리하기 번거로워서, 그냥 악보를 받고 먼저 보기 편한 방식으로 바꿔줬다(SheetMusic() 이후 ConvertSheetMusic()). 이후 반복문을 돌면서, 총 재생 시간은 몇 분인지, 노래 제목은 뭔지, 악보 구성은 어떻게 되어있는지 확인하고 검사 전 재생시간만큼 악보로 연주(?)를 해준다. 그러면 최종 검사 악보가 나오는데, 여기서 그냥 Find를 해주면 된다. -1 검사를 해도 되고 npos 검사를 실시해도 된다. 마지막으로 재생 시간이 더 길거나, 동일한 경우에는 앞서 재생된 노래를 return하기 위해 MaxPlayTime으로 조건을 판별해준다.
#include <string>
#include <vector>
#include <unordered_map>
using namespace std;
unordered_map<string, string> SheetMusicTable;
void SheetMusic()
{
SheetMusicTable["C"] = "A";
SheetMusicTable["C#"] = "B";
SheetMusicTable["D"] = "C";
SheetMusicTable["D#"] = "D";
SheetMusicTable["E"] = "E";
SheetMusicTable["F"] = "F";
SheetMusicTable["F#"] = "G";
SheetMusicTable["G"] = "H";
SheetMusicTable["G#"] = "I";
SheetMusicTable["A"] = "J";
SheetMusicTable["A#"] = "K";
SheetMusicTable["B"] = "L";
}
string ConvertSheetMusic(const string& _Input)
{
string Result;
for (size_t i = 0; i < _Input.length();)
{
string Note;
if (i + 1 < _Input.length() && _Input[i + 1] == '#')
{
Note = _Input.substr(i, 2); // e.g., C#
i += 2;
}
else
{
Note = _Input.substr(i, 1); // e.g., C
i += 1;
}
auto it = SheetMusicTable.find(Note);
if (it != SheetMusicTable.end())
{
Result += it->second;
}
else
{
Result += "?"; // Unknown note
}
}
return Result;
}
string CreateSheetMusic(int _Miin, const string& _Score)
{
string SheetMusic = "";
int MaxIndex = _Score.size() - 1;
int CurIndex = 0;
while (_Miin--)
{
if (CurIndex > MaxIndex)
{
CurIndex = 0;
}
SheetMusic += _Score[CurIndex++];
}
return SheetMusic;
}
int MinuteCalculation(const string& _Start, const string& _End)
{
string Start = _Start;
string End = _End;
int StartMin = (stoi(Start.substr(0, 2)) * 60) + stoi(Start.substr(3, 2));
int EndMin = (stoi(End.substr(0, 2)) * 60) + stoi(End.substr(3, 2));
return EndMin - StartMin;
}
// [1] 음악 제목 [2] 재생이 시작되고 끝난 시각 [3] 악보
// C, C#, D, D#, E, F, F#, G, G#, A, A#, B 12개
string solution(string m, vector<string> musicinfos)
{
string answer = "(None)";
SheetMusic();
string Newm = ConvertSheetMusic(m);
int MaxPlayTime = -1; // 현재까지의 최장 재생 시간
for (size_t i = 0; i < musicinfos.size(); i++)
{
string CurStr = musicinfos[i];
int Min = MinuteCalculation(CurStr.substr(0, 5), CurStr.substr(6, 5));
string Title = "";
string Score = "";
bool IsFirst = false;
for (size_t j = 12; j < CurStr.size(); j++)
{
char temp = CurStr[j];
if (temp != ',' && IsFirst == false)
{
Title += temp;
}
else if (temp == ',')
{
IsFirst = true;
continue;
}
else
{
Score += temp;
}
}
string NewScore = ConvertSheetMusic(Score);
string Music = CreateSheetMusic(Min, NewScore);
if (Music.find(Newm) != -1)
{
if (Min > MaxPlayTime)
{
MaxPlayTime = Min;
answer = Title;
}
}
}
return answer;
}
기본적인 구조는 DFS와 비슷하지만, 한 칸씩 이동하는 방식과 다르게 정해진 방향으로 쭉 이동해야합니다(인덱스 범위 내 or 다음 구역이 D가 아닌 경우). 이후 이동한 구역에 대해서는 현재까지의 이동 횟수(Count)를 기록합니다. 나중에 다시 돌아왔을 때 현재 Count보다 기록된 Count가 낮다면 해당 구역에서는 DFS를 다시 할 필요가 없기 때문입니다(이미 다른 구역을 모두 탐색한 곳이기 때문). 이런 식으로 이동하면서, 도달 가능한 방식을 찾고 그 중 가장 낮은 값을 리턴해줍니다.
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
vector<vector<int>> Visited;
int LimitY = 0;
int LimitX = 0;
int CheckY[4] = {-1, 0, 1, 0};
int CheckX[4] = {0, 1, 0, -1};
int answer = INT32_MAX;
bool bIsFound = false;
void DFS(vector<string>& _board, int _StartY, int _StartX, int _Count)
{
Visited[_StartY][_StartX] = _Count;
// 위, 오른쪽, 아래, 왼쪽으로 진행 가능한지 체크
// 진행이 불가능한 경우(이동 후 위치가 나와 동일한 경우)에는 실패 체크
// 위에 반복
for (size_t i = 0; i < 4; i++)
{
int TempY = _StartY;
int TempX = _StartX;
while (true)
{
TempY += CheckY[i];
TempX += CheckX[i];
if (TempY >= LimitY || TempY < 0 || TempX >= LimitX || TempX < 0) // 상하좌우가 이동 불가능한지 체크
{
TempY -= CheckY[i];
TempX -= CheckX[i];
break;
}
if (_board[TempY][TempX] == 'D')
{
TempY -= CheckY[i];
TempX -= CheckX[i];
break;
}
}
if (Visited[TempY][TempX] <= _Count)
{
continue;
}
else if (_board[TempY][TempX] == 'G')
{
// 찾음
bIsFound = true;
answer = min(_Count, answer);
}
else
{
DFS(_board, TempY, TempX, _Count + 1);
}
}
}
int solution(vector<string> board)
{
int StartY = 0, StartX = 0;
LimitY = board.size(); // 인덱스는 얘보다 -1 // 5
LimitX = board[0].size(); // 인덱스는 얘보다 -1 // 7
Visited.resize(board.size(), vector<int>(board[0].size(), INT32_MAX));
// 원래 위치로 돌아오는 경우에는 폐기
for (size_t y = 0; y < board.size(); y++)
{
for (size_t x = 0; x < board[y].size(); x++)
{
char CurDot = board[y][x];
if ('R' == CurDot)
{
StartY = static_cast<int>(y);
StartX = static_cast<int>(x);
}
}
}
int Count = 1;
DFS(board, StartY, StartX, Count);
if (bIsFound == false)
{
answer = -1;
}
return answer;
}
가끔 보면 문제를 보자마자 힌트가 보이는 것들이 있습니다. 해당 문제가 그런데, orders의 크기(갯수)와 각 요소마다의 문자열 길이를 보면 이런 문제는 그냥 완전 탐색을 진행해도 됩니다.
근데 문제가 course를 선택해서 가장 많은 조합을 도출하라고 했으니, 아예 다 돌 필요는 없습니다. 일단 모든 orders를 취합해서 문자열들(메뉴들)이 뭐가 있는지를 파악합니다. 이후 이 문자열들에서 course 갯수만큼 추출해서 모든 요소들을 검사하고, 이 조합을 선택하는 orders가 몇개인지 파악합니다. 예를 들어 AB를 탐색하기로 했으면 ABCFG에서 AB가 있는지 없는지, AC에서 AB가 있는지 없는지를 파악하는 것입니다.
이렇게 2개 조합일 때, 3개 조합일 때, 4개 조합일 때 모두 파악한 뒤 가장 많은 선택을 받았던 조합을 answer에 담고 오름차순 정렬해준 뒤 return 해줍니다.
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <algorithm>
#include <unordered_map>
using namespace std;
// 코스요리 메뉴는 최소 2가지 이상의 단품메뉴로 구성
// 최소 2명 이상의 손님으로부터 주문된 단품메뉴 조합에 대해서만 코스요리 메뉴 후보에 포함
// orders.size() : 2 이상 20 이하
// orders[i] : 2 이상 10 이하인 문자열, 대문자로만, 같은 알파벳이 중복해서 들어있지 않습니다
// course.size() : 1 이상 10 이하
// course[i] : 2 이상 10 이하인 자연수가 오름차순으로 정렬
// 각 코스요리 메뉴의 구성을 문자열 형식으로 배열에 담아 사전 순으로 오름차순 정렬해서 return
// 만약 가장 많이 함께 주문된 메뉴 구성이 여러 개라면, 모두 배열에 담아 return
vector<string> solution(vector<string> orders, vector<int> course)
{
vector<string> answer;
map<char, int> Map;
vector<char> Menus;
for (size_t i = 0; i < orders.size(); i++)
{
for (size_t j = 0; j < orders[i].size(); j++)
{
char CurMenu = orders[i][j];
if (Map.find(CurMenu) == Map.end())
{
Map.insert(make_pair(CurMenu, 1));
Menus.push_back(CurMenu);
}
}
}
sort(Menus.begin(), Menus.end());
// 원하는 조합 개수에 대해 순회
for (int c : course)
{
unordered_map<string, int> CombCount;
int MaxCount = 0;
for (const string& order : orders)
{
if (order.size() < c) continue;
string SortedOrder = order;
sort(SortedOrder.begin(), SortedOrder.end());
vector<bool> Select(SortedOrder.size(), false);
fill(Select.end() - c, Select.end(), true);
do
{
string Comb;
for (int i = 0; i < SortedOrder.size(); ++i)
{
if (Select[i])
Comb += SortedOrder[i];
}
CombCount[Comb]++;
MaxCount = max(MaxCount, CombCount[Comb]);
} while (next_permutation(Select.begin(), Select.end()));
}
// 가장 많이 등장한 조합만 answer에 추가
for (const auto& it : CombCount)
{
const string& comb = it.first;
int count = it.second;
if (count >= 2 && count == MaxCount)
{
answer.push_back(comb);
}
}
}
sort(answer.begin(), answer.end());
return answer;
}
스카이박스에서 큐브맵을 로드하여 활용해봤습니다. 큐브맵을 여기서만 쓰는게 아니고, 리플렉션 프로브를 위해 사용할 수도 있습니다. 리플렉션 프로브는 3D 실시간 렌더링에서 간접 반사를 구현하기 위한 기술로, 오브젝트 표면에 환경이 반사되는 것처럼 보이게 만드는 데 사용됩니다.
실시간 반사는 성능 부담이 크기 때문에, 리플렉션 프로브를 활용하여 특정 위치의 반사 환경을 미리 저장해두고, 주변 오브젝트에 이 큐브맵을 적용하여 효율적으로 간접 반사를 구현할 수 있습니다.
기존의 RTV, SRV를 만들던 것과 차이가 있다면, MiscFlags 값을 D3D11_RESOURCE_MISC_TEXTURECUBE로 지정해주는 것과 아래와 같이 D3D11_SUBRESOURCE_DATA에 값을 6번 넣어주고, 그걸로 Textrue2D를 Create()하는 것 정도 아닐까 싶습니다.
SkyBox는 기존에 렌더러들을 출력해주는 RenderTarget에 그리지 않고, 따로 RenderTarget을 만들어 거기에 먼저 그려둔 뒤, 나중에 최종 RenderTarget(CameraRenderTarget)에 Blending 했습니다. 먼저 Material Setting입니다.
9x9까지 해버리면 연산량이 너무 많아집니다. 이에 대한 해결책으로 [원본 이미지 다운 스케일링 -> 5x5정도의 필터 적용 -> 적용된 이미지 원래 크기로 업스케일링]을 실시할 수도 있습니다(효과는 동일한데 연산은 최적화됨). 여기서는 적용에 의의를 두는 것이기 때문에 5x5, 7x7, 9x9를 각각 실시해봤습니다.
2. OldFilm
: 특수한 텍스쳐를 사용했습니다.
예전에 Cuphead라는 게임을 모작할 때 사용했던 리소스인데, 똑같이 가져와서 적용해봤습니다.
렌더링을 하기 위해서는 렌더링 파이프라인 개념을 알아야합니다. 렌더링 파이프라인은 Direct3D에서 메모리 자원들을 GPU로 처리하여 하나의 렌더링 이미지로 만드는 일련의 과정을 말합니다.
일련의 과정에서 크게 두 가지로 나눌 수 있기도 합니다. 고정 기능 단계와 프로그래밍 가능 단계입니다.
- 고정 기능 단계 : 미리 정해진 특정 연산들이 수행되고, 상태 객체라는 개념을 이용하여 연산의 설정을 변경할 수 있으며, 사용자가 임의로 실행을 거부할 수 없음
- 프로그래밍 가능 단계 : HLSL(High Level Shading Languege)로 셰이더 프로그래밍이 가능한 단계이며, 임의로 실행을 거부할 수 있음
[단계별 정리]
단계별 핵심 내용입니다. "렌더링 결과물을 출력한다"를 위해서는 Input Assembler, Vertex Shader, Rasterizer, Pixel Shader, Output Merger만 진행하면 됩니다. Hull Shader, Tessellator, Domain Shader, Geometry Shader는 인스턴싱을 위한 과정입니다. 대표적으로 파티클이 있습니다만, 제가 사용할 프레임워크에서는 크게 다루지 않을 것 같습니다.
Input Assembler (입력 조립기 단계)
- Vertex Buffer / Index Buffer에 저장된 정점, 인덱스 데이터 수집 - 이 단계에서는 아직 쉐이더가 실행되지 않았으며, 파이프라인에 데이터가 들어오는 입구에 해당 - 설정 정보에는 [Input Layout], [Primitive Topology] 등도 포함
<Index Buffer 활용> : Vertex Buffer와 관련이 깊으며, 정점 버퍼에 있는 각 정점의 위치를 기록하기 위해 활용 : 이를 통해 Vertex Buffer의 특정 정점(Vertex)을 빠르게 찾거나 정점을 재사용 가능하게 만들어주며, Input Assembler 단계에서 사용 : 예시) 삼각형 하나를 이루기 위한 인덱스: 0, 1, 2 → 정점 0~2를 참조 : 사용 시 중복 정점 제거로 메모리를 절약하며, 그리기 명령 효율성 증가
<Vertex Buffer 활용> : 정점(Vertex)들의 위치, 노멀, UV, 색상 등(float3 Postion, float3 Normal, float3 TexCoord)의 속성을 담고 있는 버퍼로, Input Assembler에서 활용됨 : IASetVertexBuffers()로 설정 → Vertex Shader
Vertex Shader (정점 셰이더)
- 각 정점 데이터를 변환하는 첫 번째 쉐이더 - 정점마다 월드 좌표 → 클립 좌표로 변환, 애니메이션 스키닝, 라이팅 계산 등의 처리 수행 - 여기서 수행된 결과는 Pixel Shader에서 사용할 수 있도록 전달
<Constant Buffer 활용> : 셰이더에 값을 저장하는 메모리로, CPU ↔ GPU간 데이터를 전달에 사용되며, 모든 셰이더 단계에서 활용됨 : 해당 값에는 행렬(World, View, Projection 등) 정보 등 활용 : 최대 14슬롯 바인딩이 가능하며, 내부 데이터는 16byte 단위로 정렬 필요
<Sampler 활용> : 샘플러를 쓰기도 하지만, 주로 픽셀 셰이더에서 씀
Hull Shader (덮개 셰이더) [생략 가능]
- 테셀레이션의 첫 번째 단계 - 패치(기본 도형) 단위로 테셀레이션 정도를 결정하기 위해 테셀레이션 계수를 계산 - 쉐이더가 실행되면 정점 그룹 단위로 계산되며, 곡면 분할이 필요한 경우 사용
<Constant Buffer 활용>
Tessellator (테셀레이터) [생략 가능]
- 고정 기능 하드웨어 - 직접 프로그래밍은 불가능하며, Hull Shader에서 제공된 정보를 기반으로 세분화된 정점을 생성 - 삼각형, 선, 사각형 형태로 분할 가능
Domain Shader (영역 셰이더) [생략 가능]
- 테셀레이터가 생성한 세분화된 정점의 위치와 속성을 계산 - 곡면의 실제 형상 및 기하 정보를 계산하며, 여기서 생성된 정점은 이후 Geometry Shader로 전달
<Constant Buffer 활용>
Geometry Shader (기하 셰이더) [생략 가능]
- 도형(Primitive) 단위의 생성, 제거, 변형 - 삼각형을 분해하거나 더 추가할 수 있고, 쉐도우 볼륨, 실루엣, 아웃라인 효과 등에 활용 - Stream Output을 통해 이 결과를 바로 GPU 버퍼에 저장할 수도 있음
<Stream Output (스트림 출력)> : Geometry Shader 출력 결과를 GPU 메모리에 직접 저장(Geometry Shader → Stream Output → 버퍼 저장) : 파티클, 물리 시뮬레이션, LOD 캐싱, 후속 드로우콜 등에 활용 : 렌더링 외의 데이터 가공 목적에도 활용
<Constant Buffer 활용>
Rasterizer (레스터라이저)
- 벡터 → 픽셀 변환 (Triangle → Pixel) - 화면 공간으로 변환된 정점 데이터를 픽셀 단위로 분해 - Z-버퍼, 컬링, 클리핑 등의 테스트가 여기서 수행
Pixel Shader (픽셀 셰이더)
- 화면에 찍힐 각 픽셀의 색상 계산 - 텍스처, 라이팅, 그림자, 블렌딩, 쉐도우맵 등 다양한 시각 효과 구현의 중심 - 결과는 렌더 타겟으로 전달
<Constant Buffer 활용> : 정점 셰이더에서 Transform 데이터를 주로 활용한다면, 여기는 텍스쳐, Structured(빛, 델타타임) 등을 활용
<Sampler 활용> : 텍스처 필터링이나 경계 처리 방식 등을 정의한 값으로, Vertex Shader나 PixelShader에서 텍스처 샘플링 시 활용됨 : 상수버퍼와 마찬가지로 바인딩 슬롯으로 데이터가 전달되고 사용됨 : UV값에 WRAP, CLAMP 등을 설정 : SetSamplers()로 최대 16개까지 바인딩 가능
<Texture Buffer 활용> : 텍스처, 노이즈맵, 그림자 맵 등 샘플링 가능한 데이터 자원이며, Pixel Shader, Compute Shader 등에 활용됨 : Diffuse, Normal, Shadow Map 등이 있으며 SRV(Shader Resource View)로 접근 가능함 : 최대 128개까지 바인딩 가능
Output Merger (출력 병합)
- 최종 픽셀 결과를 렌더 타겟, 깊이/스텐실 버퍼에 기록 - Z-Test, 스텐실 테스트, 블렌딩 등을 수행하며 최종 출력 픽셀을 결정 - 모든 테스트를 통과한 픽셀만 화면에 그려짐