본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 2. 2. · 3 Views
Flame 라이팅과 셰이더 완벽 가이드
Flutter의 게임 엔진 Flame에서 2D 라이팅 시스템과 GLSL 셰이더를 활용하여 몰입감 있는 비주얼을 구현하는 방법을 알아봅니다. 노멀 맵부터 포스트 프로세싱, 블룸 효과까지 게임 그래픽의 핵심 기술을 다룹니다.
목차
1. 2D 라이팅 시스템
김개발 씨는 첫 번째 2D 게임을 완성했습니다. 그런데 뭔가 밋밋합니다.
분명 캐릭터도 예쁘게 그렸고, 배경도 신경 썼는데 화면이 전체적으로 평면적으로 보입니다. "프로 게임들은 어떻게 그렇게 분위기 있는 화면을 만들까요?" 선배 박시니어 씨가 웃으며 대답합니다.
"라이팅이 빠졌잖아요."
2D 라이팅 시스템은 평면적인 게임 화면에 빛과 그림자를 더해 깊이감과 분위기를 만들어내는 기술입니다. 마치 무대 조명 감독이 스포트라이트 하나로 배우를 돋보이게 만드는 것처럼, 게임에서도 광원을 배치하면 단순한 2D 그래픽이 살아 움직이는 듯한 느낌을 줍니다.
Flame 엔진에서는 이를 위해 별도의 라이팅 레이어를 구현하여 사용합니다.
다음 코드를 살펴봅시다.
// 포인트 라이트 컴포넌트 정의
class PointLight extends PositionComponent {
final double radius;
final Color color;
final double intensity;
PointLight({
required this.radius,
this.color = Colors.white,
this.intensity = 1.0,
});
@override
void render(Canvas canvas) {
// 중심에서 바깥으로 퍼지는 그라디언트 생성
final gradient = RadialGradient(
colors: [
color.withOpacity(intensity),
color.withOpacity(0.0),
],
);
final paint = Paint()..shader = gradient.createShader(
Rect.fromCircle(center: Offset.zero, radius: radius),
);
canvas.drawCircle(Offset.zero, radius, paint);
}
}
김개발 씨는 입사 6개월 차 게임 개발자입니다. 회사에서 진행 중인 던전 탐험 게임의 프로토타입을 완성했는데, 기획팀에서 피드백이 왔습니다.
"던전인데 왜 이렇게 밝아요? 횃불 들고 다니는 느낌이 안 나요." 박시니어 씨가 김개발 씨의 화면을 보더니 고개를 끄덕입니다.
"라이팅 시스템을 넣어야 해요. 2D 게임이라도 빛과 어둠이 있어야 분위기가 살아나거든요." 그렇다면 2D 라이팅이란 정확히 무엇일까요?
쉽게 비유하자면, 라이팅은 마치 연극 무대의 조명과 같습니다. 배우가 무대 위에 서 있을 때, 조명이 없으면 그저 평범한 사람일 뿐입니다.
하지만 스포트라이트가 켜지는 순간, 그 배우는 주인공이 됩니다. 주변은 어두워지고, 오직 빛이 닿는 곳만 선명해집니다.
게임에서의 라이팅도 이와 같습니다. 라이팅이 없던 시절의 2D 게임들은 어땠을까요?
모든 스프라이트가 동일한 밝기로 그려졌습니다. 던전 안이든 밖이든, 낮이든 밤이든 캐릭터와 배경은 항상 같은 색상으로 표현되었습니다.
개발자들은 밝은 버전과 어두운 버전의 그래픽을 따로 만들어야 했고, 이는 엄청난 작업량을 의미했습니다. 바로 이런 문제를 해결하기 위해 동적 라이팅 시스템이 등장했습니다.
동적 라이팅을 사용하면 하나의 그래픽 에셋으로도 다양한 조명 환경을 표현할 수 있습니다. 캐릭터가 횃불 근처에 가면 밝아지고, 멀어지면 어두워집니다.
시간에 따라 태양의 위치가 바뀌면 그림자도 함께 움직입니다. 이 모든 것이 실시간으로 계산됩니다.
위의 코드를 자세히 살펴보겠습니다. 먼저 PointLight 클래스는 PositionComponent를 상속받습니다.
이는 게임 월드 내에서 위치를 가지는 오브젝트라는 의미입니다. 광원도 결국 특정 위치에 존재하는 게임 오브젝트이기 때문입니다.
radius, color, intensity 세 가지 속성이 핵심입니다. radius는 빛이 닿는 범위를 결정합니다.
color는 광원의 색상으로, 횃불이라면 주황색, 마법 오라라면 파란색을 설정할 수 있습니다. intensity는 빛의 강도로, 0에서 1 사이의 값을 가집니다.
render 메서드에서는 RadialGradient를 사용합니다. 이 그라디언트는 중심에서 바깥으로 갈수록 투명해지는 원형 그라데이션을 만들어냅니다.
실제 빛이 퍼져나가는 모습을 자연스럽게 표현할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?
던전 크롤러 게임을 만든다고 가정해봅시다. 플레이어 캐릭터 주변에는 작은 조명을 붙이고, 벽에 설치된 횃불마다 포인트 라이트를 배치합니다.
플레이어가 이동하면 조명도 함께 움직이면서 시야가 변합니다. 이렇게 하면 탐험의 긴장감이 극대화됩니다.
하지만 주의할 점도 있습니다. 광원을 너무 많이 배치하면 성능 문제가 발생할 수 있습니다.
각 광원마다 그라디언트를 계산하고 그려야 하기 때문입니다. 따라서 화면에 보이는 광원만 렌더링하는 최적화가 필요합니다.
또한 광원끼리 겹치는 영역의 처리도 신경 써야 합니다. 다시 김개발 씨의 이야기로 돌아가봅시다.
라이팅 시스템을 구현한 후, 던전은 완전히 다른 분위기가 되었습니다. 기획팀에서 연락이 왔습니다.
"이거야, 이 느낌이야!"
실전 팁
💡 - 광원의 색상을 약간 따뜻하게(주황빛) 설정하면 아늑한 느낌을, 차갑게(파란빛) 설정하면 으스스한 느낌을 줄 수 있습니다
- 성능 최적화를 위해 화면 밖의 광원은 렌더링에서 제외하세요
- BlendMode.plus를 사용하면 여러 광원이 자연스럽게 합쳐집니다
2. 노멀 맵 활용
라이팅 시스템을 구현한 김개발 씨는 한 가지 아쉬움이 남았습니다. 빛이 비추긴 하는데, 벽돌 벽이나 바위 표면이 너무 평평해 보입니다.
실제로는 울퉁불퉁한 질감이 있어야 하는데 말입니다. 박시니어 씨가 힌트를 줍니다.
"노멀 맵을 써보세요. 3D 게임에서 쓰는 기법인데, 2D에서도 효과가 좋아요."
노멀 맵은 표면의 울퉁불퉁한 정도를 색상 정보로 저장한 특수한 텍스처입니다. RGB 값이 각각 X, Y, Z 방향의 법선 벡터를 나타내며, 이를 통해 실제로는 평평한 2D 스프라이트도 빛을 받을 때 입체적으로 보이게 할 수 있습니다.
마치 실제 조각품에 빛을 비추는 것처럼 음영이 자연스럽게 생깁니다.
다음 코드를 살펴봅시다.
// 노멀 맵을 활용한 라이팅 계산
class NormalMappedSprite extends SpriteComponent {
late ui.Image normalMap;
Vector2 lightDirection = Vector2(0, -1);
Future<void> loadNormalMap(String path) async {
normalMap = await Flame.images.load(path);
}
Vector3 getNormalAt(int x, int y) {
// 노멀 맵의 RGB 값을 벡터로 변환
// R -> X (-1 ~ 1), G -> Y (-1 ~ 1), B -> Z (0 ~ 1)
final pixel = getPixelColor(normalMap, x, y);
return Vector3(
(pixel.red / 255.0) * 2 - 1, // X: 좌우 방향
(pixel.green / 255.0) * 2 - 1, // Y: 상하 방향
pixel.blue / 255.0, // Z: 앞쪽 방향
).normalized();
}
double calculateLighting(Vector3 normal, Vector3 lightDir) {
// 램버트 반사 모델: 빛의 방향과 표면 법선의 내적
return max(0, normal.dot(-lightDir));
}
}
김개발 씨는 노멀 맵이라는 단어를 처음 들어봤습니다. 구글링을 해보니 온통 보라색과 파란색이 섞인 이상한 이미지들이 나옵니다.
도대체 이게 뭘까요? 박시니어 씨가 설명을 시작합니다.
"노멀 맵의 원리를 이해하려면 먼저 법선 벡터가 뭔지 알아야 해요." 쉽게 비유하자면, 법선 벡터는 표면에서 수직으로 뻗어나가는 화살표와 같습니다. 평평한 책상 위에서는 모든 화살표가 위를 향합니다.
하지만 울퉁불퉁한 바위 표면에서는 각 지점마다 화살표가 제각각 다른 방향을 가리킵니다. 이 화살표의 방향에 따라 빛이 어떻게 반사되는지가 결정됩니다.
그렇다면 노멀 맵은 이 화살표 정보를 어떻게 저장할까요? 바로 색상으로 저장합니다.
RGB 각 채널이 X, Y, Z 방향을 담당합니다. 빨간색(R)은 좌우 방향, 초록색(G)은 상하 방향, 파란색(B)은 앞뒤 방향입니다.
그래서 노멀 맵을 보면 대부분 파란색 계열인데, 이는 대부분의 표면이 앞쪽(화면 바깥)을 향하고 있기 때문입니다. 노멀 맵이 없던 시절에는 어떻게 했을까요?
입체감을 표현하려면 미리 음영이 그려진 여러 장의 스프라이트를 준비해야 했습니다. 빛이 왼쪽에서 오는 버전, 오른쪽에서 오는 버전, 위에서 오는 버전...
이렇게 준비해야 할 이미지가 기하급수적으로 늘어났습니다. 게다가 빛이 실시간으로 움직이는 상황에서는 적용이 불가능했습니다.
노멀 맵을 사용하면 이 모든 문제가 해결됩니다. 하나의 노멀 맵만 있으면 빛이 어느 방향에서 오든 실시간으로 올바른 음영을 계산할 수 있습니다.
횃불을 들고 동굴 벽을 지나가면, 벽돌의 홈과 튀어나온 부분이 빛에 따라 살아 움직이는 듯한 효과를 볼 수 있습니다. 위의 코드를 단계별로 살펴보겠습니다.
getNormalAt 메서드가 핵심입니다. 노멀 맵 이미지에서 특정 픽셀의 색상을 읽어와 벡터로 변환합니다.
색상값은 0에서 255 사이이므로, 이를 -1에서 1 사이의 값으로 변환해야 합니다. 이것이 바로 (pixel.red / 255.0) * 2 - 1 공식의 의미입니다.
calculateLighting 메서드에서는 램버트 반사 모델을 사용합니다. 이는 가장 기본적인 조명 모델로, 빛의 방향과 표면 법선의 내적(dot product)으로 밝기를 계산합니다.
빛이 표면에 수직으로 들어오면 가장 밝고, 비스듬히 들어오면 어두워집니다. 실제 게임에서는 어떻게 적용할까요?
던전의 벽돌 벽 텍스처가 있다면, 같은 크기의 노멀 맵을 따로 준비합니다. 포토샵이나 GIMP의 노멀 맵 생성 필터를 사용하면 일반 텍스처에서 자동으로 노멀 맵을 만들 수 있습니다.
이 두 이미지를 함께 사용하면 벽돌 하나하나에 빛이 자연스럽게 닿는 효과를 볼 수 있습니다. 주의할 점도 있습니다.
노멀 맵의 해상도가 너무 높으면 매 프레임마다 많은 픽셀을 계산해야 하므로 성능 저하가 발생합니다. 또한 노멀 맵은 반드시 원본 텍스처와 정확히 같은 크기여야 합니다.
크기가 다르면 음영이 엉뚱한 곳에 생깁니다. 김개발 씨는 노멀 맵을 적용한 벽을 보고 감탄했습니다.
횃불을 들고 지나가자 벽돌의 질감이 살아 움직이는 것 같았습니다. "이게 2D라고요?"
실전 팁
💡 - 무료 노멀 맵 생성 도구로는 NormalMap-Online, GIMP의 Normalmap 플러그인이 있습니다
- 성능이 중요하다면 셰이더에서 노멀 맵 계산을 처리하는 것이 효율적입니다
- 노멀 맵의 강도를 조절하려면 Z 값(파란색)을 기준으로 X, Y 값을 스케일링하세요
3. GLSL 셰이더 기초
김개발 씨의 게임이 점점 발전하고 있습니다. 그런데 CPU에서 픽셀 단위로 조명을 계산하다 보니 프레임이 뚝뚝 떨어집니다.
박시니어 씨가 말합니다. "이제 셰이더를 배워야 할 때가 됐어요.
GPU의 힘을 빌려야 해요." 셰이더라는 단어에 김개발 씨는 긴장이 됩니다. 어렵다는 소문을 많이 들었기 때문입니다.
**GLSL(OpenGL Shading Language)**은 GPU에서 실행되는 프로그램을 작성하는 언어입니다. 수천 개의 픽셀을 동시에 병렬 처리할 수 있어 그래픽 효과를 실시간으로 구현하는 데 필수적입니다.
Flutter와 Flame에서는 FragmentProgram API를 통해 GLSL 셰이더를 사용할 수 있으며, 이를 통해 화려한 시각 효과를 효율적으로 구현할 수 있습니다.
다음 코드를 살펴봅시다.
// basic_shader.frag - GLSL 프래그먼트 셰이더
#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uResolution; // 화면 해상도
uniform float uTime; // 경과 시간
uniform sampler2D uTexture; // 입력 텍스처
out vec4 fragColor; // 출력 색상
void main() {
// 현재 픽셀의 UV 좌표 계산 (0~1 범위)
vec2 uv = FlutterFragCoord().xy / uResolution;
// 텍스처에서 색상 샘플링
vec4 color = texture(uTexture, uv);
// 시간에 따라 밝기 변화 (간단한 애니메이션)
float brightness = 0.5 + 0.5 * sin(uTime);
// 최종 색상 출력
fragColor = color * brightness;
}
셰이더라는 단어를 들으면 많은 개발자들이 겁을 먹습니다. 뭔가 어렵고 복잡한 것 같기 때문입니다.
하지만 박시니어 씨는 이렇게 말합니다. "기본 원리만 이해하면 그렇게 어렵지 않아요." 셰이더를 이해하려면 먼저 GPU와 CPU의 차이를 알아야 합니다.
CPU는 마치 천재 한 명이 복잡한 문제를 순서대로 푸는 것과 같습니다. 빠르고 똑똑하지만, 한 번에 하나의 일만 할 수 있습니다.
반면 GPU는 평범한 학생 수천 명이 동시에 간단한 계산을 하는 것과 같습니다. 개인의 능력은 CPU에 비해 떨어지지만, 수천 개의 계산을 동시에 처리할 수 있습니다.
게임 화면에는 수백만 개의 픽셀이 있습니다. 각 픽셀의 색상을 CPU로 하나씩 계산하면 너무 오래 걸립니다.
하지만 GPU를 사용하면 모든 픽셀을 동시에 계산할 수 있습니다. 이것이 셰이더의 핵심입니다.
GLSL은 이 GPU에게 명령을 내리는 언어입니다. 코드를 보면 먼저 uniform 변수들이 보입니다.
uniform은 셰이더 외부에서 전달되는 값입니다. uResolution은 화면 크기, uTime은 현재 시간, uTexture는 처리할 이미지입니다.
이 값들은 모든 픽셀에서 동일하게 사용됩니다. main 함수가 셰이더의 핵심입니다.
이 함수는 화면의 모든 픽셀에 대해 실행됩니다. 1920x1080 해상도라면 약 200만 번 실행되는 것입니다.
하지만 걱정하지 마세요. GPU가 이 200만 개의 함수를 동시에 병렬로 실행합니다.
**FlutterFragCoord()**는 현재 처리 중인 픽셀의 좌표를 반환합니다. 이를 화면 해상도로 나누면 0에서 1 사이의 UV 좌표를 얻을 수 있습니다.
UV 좌표는 텍스처에서 색상을 가져올 때 사용합니다. texture 함수는 텍스처에서 특정 좌표의 색상을 가져옵니다.
이렇게 가져온 색상에 brightness를 곱해 밝기를 조절합니다. sin 함수와 시간을 조합하면 자연스러운 밝기 변화 애니메이션을 만들 수 있습니다.
마지막으로 fragColor에 최종 색상을 저장하면 해당 픽셀이 그 색상으로 표시됩니다. vec4는 RGBA 네 개의 값을 담는 벡터입니다.
Flutter에서 이 셰이더를 사용하려면 어떻게 해야 할까요? 먼저 .frag 파일을 프로젝트에 추가하고, pubspec.yaml의 shaders 섹션에 등록합니다.
그런 다음 FragmentProgram.fromAsset을 사용해 셰이더를 로드하고, CustomPainter에서 사용할 수 있습니다. 주의할 점이 있습니다.
셰이더 코드에서는 조건문과 반복문 사용을 최소화해야 합니다. GPU는 모든 픽셀을 동일한 명령으로 처리하도록 설계되어 있어서, 분기가 많으면 성능이 크게 저하됩니다.
또한 셰이더 컴파일 에러는 런타임에 발생하므로, 문법 오류를 꼼꼼히 확인해야 합니다. 김개발 씨는 처음에는 GLSL 문법이 낯설었지만, 몇 번 연습하니 익숙해졌습니다.
"생각보다 할 만하네요!"
실전 팁
💡 - GLSL에서 float 리터럴은 반드시 소수점을 포함해야 합니다 (1이 아닌 1.0)
- vec2, vec3, vec4의 컴포넌트는 .xy, .rgb 등으로 접근할 수 있습니다
- 셰이더 디버깅은 색상 출력으로 하세요 (fragColor = vec4(debug_value, 0, 0, 1))
4. 커스텀 셰이더 작성
GLSL 기초를 익힌 김개발 씨는 이제 실제 게임에 적용할 셰이더를 만들고 싶어졌습니다. 첫 번째 목표는 플레이어가 물속에 들어갔을 때 화면이 일렁이는 효과입니다.
박시니어 씨가 조언합니다. "좋은 선택이에요.
파동 효과는 셰이더의 기본이면서도 결과물이 인상적이거든요."
커스텀 셰이더를 작성하면 게임에 독특한 시각 효과를 추가할 수 있습니다. UV 좌표를 왜곡하면 물결이나 열기 효과를, 색상을 조작하면 흑백이나 세피아 효과를 만들 수 있습니다.
Flutter에서는 FragmentShader 클래스를 통해 Dart 코드와 GLSL 셰이더를 연결하고, CustomPainter에서 셰이더가 적용된 화면을 렌더링합니다.
다음 코드를 살펴봅시다.
// water_wave.frag - 물결 왜곡 셰이더
#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uResolution;
uniform float uTime;
uniform sampler2D uTexture;
uniform float uWaveStrength; // 파동 강도
uniform float uWaveFrequency; // 파동 빈도
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uResolution;
// 사인파를 이용한 UV 왜곡
float waveX = sin(uv.y * uWaveFrequency + uTime * 2.0) * uWaveStrength;
float waveY = cos(uv.x * uWaveFrequency + uTime * 2.0) * uWaveStrength;
// 왜곡된 좌표로 텍스처 샘플링
vec2 distortedUV = uv + vec2(waveX, waveY);
distortedUV = clamp(distortedUV, 0.0, 1.0);
vec4 color = texture(uTexture, distortedUV);
// 수중 느낌을 위한 푸른 틴트 추가
color.rgb = mix(color.rgb, vec3(0.2, 0.4, 0.8), 0.2);
fragColor = color;
}
물속에 들어갔을 때 세상이 어떻게 보이나요? 모든 것이 일렁이고, 약간 푸르스름하게 보입니다.
이런 효과를 코드로 구현하려면 어떻게 해야 할까요? 김개발 씨는 머리를 굴립니다.
"화면의 각 픽셀 위치를 조금씩 움직이면 되지 않을까요?" 정확합니다. 이것이 바로 UV 왜곡의 원리입니다.
쉽게 비유하자면, 투명 비닐에 그림을 그려놓고 물 위에 띄운다고 상상해보세요. 잔물결이 일면 그림이 일그러져 보입니다.
그림 자체가 변한 것이 아니라, 우리가 보는 위치가 출렁이는 것입니다. 셰이더에서도 마찬가지로 텍스처 자체를 변형하는 것이 아니라, 텍스처를 읽어오는 좌표를 흔들어줍니다.
코드의 핵심 부분을 살펴보겠습니다. sin과 cos 함수가 파동을 만들어냅니다.
sin 함수는 -1에서 1 사이를 부드럽게 오가는 파동을 생성합니다. 여기에 uv.y를 곱하면 Y 좌표에 따라 다른 위상의 파동이 생기고, uTime을 더하면 시간에 따라 파동이 이동합니다.
uWaveStrength는 파동의 진폭입니다. 값이 클수록 화면이 많이 흔들립니다.
보통 0.01에서 0.05 사이의 값을 사용합니다. uWaveFrequency는 파동의 빈도로, 값이 클수록 잔물결이 많아집니다.
clamp 함수는 중요한 안전장치입니다. 왜곡된 UV 좌표가 0보다 작거나 1보다 크면 텍스처 범위를 벗어나게 됩니다.
clamp를 사용해 항상 유효한 범위 내에 머물도록 보장합니다. 마지막으로 mix 함수를 사용해 푸른 색조를 섞어줍니다.
mix(a, b, t)는 a와 b를 t 비율로 섞습니다. 여기서는 원래 색상에 20%의 파란색을 더해 수중 느낌을 냅니다.
Flutter에서 이 셰이더를 사용하는 코드도 살펴볼까요? FragmentShader 객체를 생성한 후, setFloat 메서드로 uniform 값들을 설정합니다.
순서가 중요한데, GLSL 코드에서 uniform을 선언한 순서대로 인덱스가 할당됩니다. setImageSampler로 텍스처를 연결하고, canvas.drawRect에 셰이더를 적용한 Paint를 전달하면 됩니다.
실무에서 주의할 점이 있습니다. 셰이더 효과는 전체 화면에 적용될 때 비용이 큽니다.
물속 영역만 따로 렌더링하고 그 부분에만 셰이더를 적용하는 것이 효율적입니다. 또한 모바일 기기에서는 uniform 개수와 텍스처 샘플링 횟수를 최소화해야 합니다.
김개발 씨는 플레이어가 물에 뛰어들 때 화면이 일렁이는 것을 보고 감탄했습니다. 단 몇 줄의 셰이더 코드로 이런 효과를 만들 수 있다니 놀라웠습니다.
실전 팁
💡 - uniform 값 변경은 매 프레임 호출되므로 불필요한 계산을 피하세요
- 모바일에서는 highp 대신 mediump precision을 사용하면 성능이 향상됩니다
- 셰이더 효과 강도는 게임 설정에서 조절할 수 있게 하면 저사양 기기 대응에 좋습니다
5. 포스트 프로세싱
게임의 비주얼이 많이 좋아졌지만, 김개발 씨는 여전히 뭔가 부족하다고 느낍니다. 유명 게임들을 보면 화면 전체에 통일된 색감이 있고, 가장자리가 자연스럽게 어두워지는 효과도 있습니다.
박시니어 씨가 말합니다. "그건 포스트 프로세싱이에요.
모든 렌더링이 끝난 후에 화면 전체에 효과를 입히는 거죠."
포스트 프로세싱은 게임 화면이 완성된 후 최종 이미지에 적용하는 후처리 효과입니다. 색상 보정, 비네트(화면 가장자리 어둡게), 색수차, 필름 그레인 등 다양한 효과를 포함합니다.
마치 사진 편집 앱의 필터처럼, 같은 게임 화면도 포스트 프로세싱에 따라 완전히 다른 분위기를 낼 수 있습니다.
다음 코드를 살펴봅시다.
// post_process.frag - 비네트 + 색상 보정 셰이더
#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uResolution;
uniform sampler2D uTexture;
uniform float uVignetteStrength; // 비네트 강도
uniform float uContrast; // 대비
uniform float uSaturation; // 채도
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uResolution;
vec4 color = texture(uTexture, uv);
// 1. 비네트 효과: 중앙에서 멀어질수록 어두워짐
vec2 center = uv - 0.5;
float vignette = 1.0 - dot(center, center) * uVignetteStrength;
color.rgb *= vignette;
// 2. 대비 조절
color.rgb = (color.rgb - 0.5) * uContrast + 0.5;
// 3. 채도 조절 (그레이스케일과 믹스)
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(vec3(gray), color.rgb, uSaturation);
fragColor = color;
}
여러분은 인스타그램 필터를 써본 적이 있나요? 평범한 사진도 필터 하나로 분위기가 확 달라집니다.
게임에서의 포스트 프로세싱도 이와 같습니다. 김개발 씨가 묻습니다.
"그런데 왜 처음부터 예쁜 색으로 그리지 않고, 나중에 효과를 입히나요?" 좋은 질문입니다. 박시니어 씨가 답합니다.
"유연성 때문이에요. 낮에는 따뜻한 색감, 밤에는 차가운 색감, 던전에서는 어두운 톤.
각각의 에셋을 따로 만들 수는 없잖아요?" 포스트 프로세싱은 게임 화면 전체를 하나의 이미지로 보고 처리합니다. 먼저 비네트(Vignette) 효과를 살펴봅시다.
비네트는 화면 가장자리를 자연스럽게 어둡게 만드는 효과입니다. 오래된 카메라로 찍은 사진에서 볼 수 있는 효과인데, 플레이어의 시선을 화면 중앙으로 유도하는 역할을 합니다.
코드에서 uv - 0.5를 하면 화면 중앙이 (0, 0)이 됩니다. **dot(center, center)**는 중앙에서의 거리의 제곱을 반환합니다.
이 값이 클수록(가장자리에 가까울수록) vignette 값이 작아지고, 결과적으로 색상이 어두워집니다. 대비(Contrast) 조절은 밝은 부분과 어두운 부분의 차이를 조절합니다.
(color - 0.5) * contrast + 0.5 공식을 사용하는데, 0.5를 빼서 중심을 맞추고, contrast를 곱한 후, 다시 0.5를 더합니다. contrast가 1보다 크면 대비가 강해지고, 1보다 작으면 부드러워집니다.
채도(Saturation) 조절은 색의 선명도를 바꿉니다. 먼저 현재 색상의 그레이스케일 값을 계산합니다.
인간의 눈은 녹색에 가장 민감하고, 파란색에 가장 둔감하기 때문에 0.299, 0.587, 0.114라는 가중치를 사용합니다. 이 그레이스케일과 원본 색상을 mix하여 채도를 조절합니다.
실무에서 포스트 프로세싱은 어떻게 구현할까요? 일반적으로 게임 화면을 먼저 오프스크린 텍스처에 렌더링합니다.
이 텍스처를 포스트 프로세싱 셰이더의 입력으로 사용하고, 결과를 실제 화면에 출력합니다. Flame에서는 RenderTexture나 Picture를 활용하여 이런 파이프라인을 구성할 수 있습니다.
여러 효과를 조합할 때는 순서가 중요합니다. 일반적으로 블룸처럼 빛을 확산시키는 효과를 먼저 적용하고, 색상 보정을 나중에 합니다.
비네트는 보통 맨 마지막에 적용합니다. 순서가 바뀌면 의도하지 않은 결과가 나올 수 있으니 주의하세요.
김개발 씨는 비네트와 색상 보정을 적용한 후 게임 화면을 다시 봤습니다. 같은 그래픽인데 훨씬 프로페셔널해 보였습니다.
"필터 하나의 힘이 이렇게 크다니!"
실전 팁
💡 - 포스트 프로세싱은 전체 화면을 처리하므로 셰이더는 최대한 가볍게 작성하세요
- 효과 강도는 0으로 설정하면 무효화되도록 구현하여, 설정에서 끌 수 있게 하세요
- 색맹 모드 같은 접근성 기능도 포스트 프로세싱으로 구현할 수 있습니다
6. 블룸과 글로우 효과
게임의 마지막 퍼즐 조각이 남았습니다. 마법 스킬을 사용할 때 빛이 번지는 효과, 네온사인처럼 빛나는 UI.
김개발 씨가 가장 구현하고 싶었던 효과입니다. 박시니어 씨가 말합니다.
"블룸 효과를 배울 차례네요. 조금 복잡하지만, 그만큼 임팩트가 큽니다."
**블룸(Bloom)**은 밝은 부분의 빛이 주변으로 번지는 효과입니다. 실제 카메라로 밝은 광원을 촬영할 때 나타나는 현상을 시뮬레이션한 것으로, HDR(High Dynamic Range) 느낌을 줍니다.
구현은 밝은 영역 추출, 블러 처리, 원본과 합성의 세 단계로 이루어집니다. 글로우 효과도 같은 원리로 특정 오브젝트를 빛나게 만들 수 있습니다.
다음 코드를 살펴봅시다.
// bloom_extract.frag - 밝은 영역 추출
#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uResolution;
uniform sampler2D uTexture;
uniform float uThreshold; // 밝기 임계값
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uResolution;
vec4 color = texture(uTexture, uv);
// 픽셀의 밝기 계산
float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
// 임계값보다 밝은 부분만 추출
if (brightness > uThreshold) {
// 부드러운 전환을 위해 smoothstep 사용
float intensity = smoothstep(uThreshold, uThreshold + 0.1, brightness);
fragColor = color * intensity;
} else {
fragColor = vec4(0.0);
}
}
// bloom_blur.frag - 가우시안 블러 (별도 파일)
// bloom_composite.frag - 원본과 블룸 합성 (별도 파일)
밤에 가로등을 바라보면 빛이 주변으로 퍼져 보입니다. 이것이 바로 블룸 현상입니다.
우리 눈이나 카메라 렌즈가 밝은 빛을 완벽하게 처리하지 못해 생기는 현상인데, 게임에서는 이를 의도적으로 재현하여 밝은 물체를 더욱 빛나 보이게 합니다. 블룸 효과는 세 단계로 구현됩니다.
박시니어 씨가 하나씩 설명해줍니다. 첫 번째 단계는 **밝은 영역 추출(Extraction)**입니다.
원본 이미지에서 일정 밝기 이상인 픽셀만 골라냅니다. 위 코드에서 **dot(color.rgb, vec3(0.2126, 0.7152, 0.0722))**가 휘도(luminance)를 계산하는 공식입니다.
이 값이 uThreshold보다 크면 해당 픽셀을 유지하고, 작으면 검은색으로 만듭니다. smoothstep 함수가 중요합니다.
단순히 임계값을 기준으로 0과 1로 나누면 경계가 딱딱해 보입니다. smoothstep은 임계값 근처에서 부드럽게 전환되는 곡선을 만들어 자연스러운 결과를 냅니다.
두 번째 단계는 블러(Blur) 처리입니다. 추출된 밝은 부분에 가우시안 블러를 적용합니다.
빛이 주변으로 퍼지는 효과를 시뮬레이션하는 것입니다. 성능을 위해 보통 다운샘플링을 함께 사용합니다.
원본의 1/4이나 1/8 크기로 줄인 후 블러를 적용하면 같은 블러 반경으로도 더 넓은 번짐 효과를 얻을 수 있습니다. 가우시안 블러는 2-pass 분리 블러로 구현하면 효율적입니다.
가로 방향으로 블러를 한 번, 세로 방향으로 한 번. 이렇게 하면 N x N 샘플링이 2N 샘플링으로 줄어듭니다.
세 번째 단계는 **합성(Composite)**입니다. 블러 처리된 밝은 부분을 원본 이미지에 더합니다.
additive blending을 사용하면 자연스럽습니다. 이때 블룸의 강도를 조절하는 uniform을 두면 효과의 세기를 실시간으로 변경할 수 있습니다.
실무에서 블룸을 구현할 때의 팁을 알려드리겠습니다. 다중 해상도 블룸이 더 예쁩니다.
1/2, 1/4, 1/8, 1/16 크기로 다운샘플링하면서 블러를 적용하고, 이를 모두 합치면 작은 광원은 살짝, 큰 광원은 넓게 번지는 자연스러운 효과가 납니다. 하지만 그만큼 렌더 패스가 늘어나므로 성능과 품질 사이의 균형을 잡아야 합니다.
글로우 효과는 블룸의 응용입니다. 특정 오브젝트만 빛나게 하고 싶다면, 해당 오브젝트를 별도의 렌더 타겟에 그리고 그 부분에만 블룸을 적용합니다.
UI의 선택된 버튼, 수집한 아이템, 활성화된 스킬 아이콘 등에 활용할 수 있습니다. 주의할 점이 있습니다.
블룸 강도가 너무 세면 화면이 뿌옇게 보입니다. 플레이어들이 "눈이 아프다"고 할 수 있으니 적절한 수준을 유지하세요.
또한 밤 장면에서는 블룸이 효과적이지만, 밝은 낮 장면에서는 과도해 보일 수 있으므로 환경에 따라 강도를 조절하는 것이 좋습니다. 김개발 씨는 마법 스킬에 블룸 효과를 적용했습니다.
주문을 시전할 때 빛이 화면 전체로 번지는 모습이 정말 멋졌습니다. "드디어 진짜 게임 같아요!" 박시니어 씨가 말합니다.
"이제 라이팅과 셰이더의 기초를 모두 배웠네요. 나머지는 이것들을 조합하고 응용하는 거예요."
실전 팁
💡 - 블룸 임계값은 0.8에서 1.0 사이에서 시작하여 원하는 결과가 나올 때까지 조절하세요
- 다운샘플링 시 bilinear filtering이 자동으로 평균화해주므로 별도 블러 없이도 약간의 효과가 납니다
- HDR 렌더링과 함께 사용하면 더욱 다이나믹한 결과를 얻을 수 있습니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
AAA급 게임 프로젝트 완벽 가이드
Flutter와 Flame 엔진을 활용하여 AAA급 퀄리티의 모바일 게임을 개발하는 전체 과정을 다룹니다. 기획부터 앱 스토어 출시까지, 실무에서 필요한 모든 단계를 이북처럼 술술 읽히는 스타일로 설명합니다.
빌드와 배포 자동화 완벽 가이드
Flutter 앱 개발에서 GitHub Actions를 활용한 CI/CD 파이프라인 구축부터 앱 스토어 자동 배포까지, 초급 개발자도 쉽게 따라할 수 있는 빌드 자동화의 모든 것을 다룹니다.
게임 분석과 메트릭스 완벽 가이드
Flutter와 Flame으로 개발한 게임의 성공을 측정하고 개선하는 방법을 배웁니다. Firebase Analytics 연동부터 A/B 테스팅, 리텐션 분석까지 데이터 기반 게임 운영의 모든 것을 다룹니다.
게임 보안과 치팅 방지 완벽 가이드
Flutter와 Flame 게임 엔진에서 클라이언트 보안부터 서버 검증까지, 치터들로부터 게임을 보호하는 핵심 기법을 다룹니다. 초급 개발자도 쉽게 따라할 수 있는 실전 보안 코드와 함께 설명합니다.
애니메이션 시스템 커스터마이징 완벽 가이드
Flutter와 Flame 게임 엔진에서 고급 애니메이션 시스템을 구현하는 방법을 다룹니다. 스켈레탈 애니메이션부터 절차적 애니메이션까지, 게임 개발에 필요한 핵심 애니메이션 기법을 실무 예제와 함께 배워봅니다.