MOONSUN
[그래픽스] Domain Warping 기반 Somoke 패턴 구현 본문
연기는 단순한 텍스처 애니메이션으로는 잘 표현되지 않는다.
실제 연기는 형태가 끊임없이 변하고, 흐르고, 말리며, 사라진다 등.. 의 움직임이 있다.
이번 글에서는 이전 글에서 알아보았던 Noise → FBM → Domain Warping 구조를 기반으로,
텍스처 없이 연기의 움직임을 표현한 Procedural Smoke Pixel Shader 구현 과정을 정리해보려고 한다.
Domain Warping의 핵심 아이디어
노이즈로 좌표를 왜곡하고,
왜곡된 좌표에서 다시 노이즈를 샘플링한다.
이 구조를 토대로 구현해보았다.
0. 개요
텍스처나 시뮬레이션 데이터를 사용하지 않고,
수학적 함수(Noise)와 시간(Time) 만으로 연기의 형태와 움직임을 생성하는 것을 목표로 했다.
핵심 아이디어는 다음과 같은 흐름으로 구성했다.
Noise → FBM → Domain Warping → Flow(Advection) → Height Fade
목표
→ 정적인 사각형 화면 위에 실제 연기처럼
- 위로 상승하고
- 좌우로 흔들리며
- 위로 갈수록 얇아지는
- 자연스러운 연기 패턴을 시각화한다.
1. Pixel Shader 흐름
1-1. Hash 함수 – 난수의 시작점
2D좌표 p를 입력으로 받아서 0~1 범위의 의사 난수 값을 생성한다.
float Hash(float2 p)
{
return frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453);
}
GPU에는 진짜 랜덤 함수가 없기 때문에, 좌표 기반으로 항상 동일한 난수를 만들어내는 함수가 필요했다.
- dot()으로 좌표를 하나의 값으로 압축
- sin()과 큰 상수를 곱해 난수처럼 보이게 변형
- frac()으로 소수 부분만 남겨 0~1 범위로 제한
이 Hash 함수는 Noise, FBM, Domain Warping의 기초 재료 역할을 한다.
1-2. Noise 함수 – 부드러운 랜덤 패턴
UV 좌표를 입력받아 부드럽게 변화하는 랜덤 값을 생성한다.
float Noise(float2 uv)
{
float2 i = floor(uv);
float2 f = frac(uv);
// 4개의 꼭지점에서 난수값을 가져옴
float a = Hash(i);
float b = Hash(i + float2(1,0));
float c = Hash(i + float2(0,1));
float d = Hash(i + float2(1,1));
// SmoothStep 보간
float2 u = f * f * (3.0 - 2.0 * f);
// 사각형 내부를 부드럽게 보간
float lerp_x0 = lerp(a,b,u.x);
float lerp_x1 = lerp(c,d,u.x);
return lerp(lerp_x0, lerp_x1,u.y);
}
픽셀 단위로 자연스럽게 연결된 랜덤 패턴 생성
- UV를 정수 그리드(i)와 그 안의 위치(f)로 분리
- 그리드 꼭짓점 4곳에서 Hash 난수값 가져오기
- Smoothstep 보간으로 값의 급격한 변화를 제거
- Bilinear interpolation(양선형 보간법)으로 사각형 내부 값을 계산
- bilinear interpolation : linear interpolation을 x축과 y축으로 두 번 적용하여 값을 유추하는 방법이다.
1-3. FBM – 여러 Noise를 쌓아 자연스럽게
Noise를 여러 번 겹쳐서 자연계에서 보이는 복잡한 패턴을 생성한다.
float FBM(float2 uv)
{
float value = 0.0;
float amplitude = 0.5; // 초기 진폭
float frequency = 1.0; // 초기 주파수
// 여러 옥타브(Noise 레이어)를 합성
for(int i=0;i<4;i++)
{
value += amplitude * Noise(uv * frequency);
frequency *= 2.0; // 다음 레이어는 주파수를 2배로
amplitude *= 0.5; // 진폭은 반으로 줄여서 영향력 감소
}
return value;
}
- 낮은 주파수 → 큰 연기 덩어리
- 높은 주파수 → 작은 디테일과 잔난류
하나의 Noise만 사용하면 단조롭기 때문에, 서로 다른 크기의 패턴을 합성하여 연기처럼 보이도록 했다.
2. 연기 움직임 설계 (Flow + Domain Warping)
2-1. 기본 상승 흐름 (Advection)
연기는 기본적으로 위로 상승한다는 물리적 특성을 구현
float2 flowDir = float2(0.0, -1.0);
float2 advectedUV = uv + flowDir * time * 0.1;
시간에 따라 UV 자체를 위쪽으로 이동시켜, 연기 전체가 화면 위로 흐르는 느낌을 만듦
2-2. 큰 흐름 (Large Scale Warping)
연기 덩어리 단위의 느리고 큰 흔들림을 구현
float2 largeWarp = float2(
FBM(advectedUV * 0.6 + time * 0.05),
FBM(advectedUV * 0.6 - time * 0.05)
);
- 낮은 주파수
- 느린 시간 변화
- 연기 전체가 휘어지는 방향성 형성
2-3. 작은 난류 (Small Turbulence)
연기 표면의 미세한 요동과 디테일을 구현
float2 smallWarp = float2(
FBM(advectedUV * 2.0 + time * 0.2),
FBM(advectedUV * 2.0 - time * 0.2)
);
- 높은 주파수
- 빠른 변화
- 연기가 끓거나 퍼지는 느낌을 추가
2-4. Domain Warping 합성
큰 흐름 + 작은 난류를 함께 적용한 Domain Warping
float2 warp = largeWarp * 1.2 + smallWarp * 0.4;
// 왜곡된 좌표에 FBM을 다시 적용: 연기의 진하고 옅은 부분(밀도) 생성
float density = FBM(advectedUV + warp);
- 연기가 일직선으로 움직이지 않음
- 반복 패턴이 눈에 띄지 않음
- 자연스러운 일렁임 형성
3. 높이에 따른 밀도 감쇠 (Height Fade)
연기가 위로 갈수록 얇아지고 사라지는 현상 구현
float fadeNoise = FBM(uv * 1.5);
float heightFade = smoothstep(1.0, 0.2, uv.y + fadeNoise * 0.1);
density *= heightFade; // 최종 밀도에 높이 감쇠 적용
- uv.y를 이용해 화면 상단으로 갈수록 감쇠
- Noise를 섞어 균일하게 사라지지 않도록 조정
4. 최종 색상 출력
float4 smokeColor = float4(0.8, 0.8, 0.8, density);
return smokeColor;
- RGB : 연한 회색 → 연기 색상
- Alpha : FBM 기반 밀도값 → 픽셀별 투명도
5. 전체 흐름 요약
| 역할 | 설명 | |
| 1 | Hash | 좌표 기반 난수 생성 |
| 2 | Noise | 부드러운 랜덤 패턴 형성 |
| 3 | FBM | 여러 크기의 패턴 합성 |
| 4 | Advection | 연기의 기본 상승 흐름 |
| 5 | Domain Warping | 큰 흐름 + 작은 난류로 자연스러운 일렁임 생성 |
| 6 | Height Fade | 위로 갈수록 연기가 옅어지는 효과 |
| 7 | Density → Alpha | 연기 투명도 결정 |
6. 전체 코드
자세한 구현 내용은 아래 깃허브에 올려져있다.
https://github.com/MoonSun-v/D3DProgramming/tree/main/D3DProgramming/02_1.SmokeRectangle
#include <Shared.fxh>
//--------------------------------------------------------------------------------------
// Pixel Shader
//--------------------------------------------------------------------------------------
// [ Hash 함수 ]
// 0~1 사이의 난수 값 반환
// - dot()으로 좌표를 하나의 값으로 압축
// - sin()과 큰 상수를 곱해 난수처럼 보이게 변형
// - frac()으로 소수 부분만 남겨 0~1 범위로 제한
float Hash(float2 p)
{
return frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453);
}
// [ Noise 함수 ]
// uv 좌표를 넣으면 부드러운 변화하는 랜덤 값을 반환
// - UV를 정수 그리드(i)와 그 안의 위치(f)로 분리
// - 그리드 꼭짓점 4곳에서 Hash 난수값 가져오기
// - Smoothstep 보간으로 값의 급격한 변화를 제거
// - Bilinear interpolation(양선형 보간법)으로 사각형 내부 값을 계산
float Noise(float2 uv)
{
float2 i = floor(uv); // 현재 좌표의 정수 부분 (그리드 위치)
float2 f = frac(uv); // 소수 부분 (그리드 내 위치, 0~1)
float a = Hash(i); // 왼쪽 아래
float b = Hash(i + float2(1, 0)); // 오른쪽 아래
float c = Hash(i + float2(0, 1)); // 왼쪽 위
float d = Hash(i + float2(1, 1)); // 오른쪽 위
float2 u = f * f * (3.0 - 2.0 * f); // Smoothstep 보간값 계산
// bilinear interpolation (사각형 내부를 부드럽게 보간)
// bilinear interpolation : linear interpolation을 x축과 y축으로 두 번 적용하여 값을 유추하는 방법
float lerp_x0 = lerp(a, b, u.x); // 아래쪽
float lerp_x1 = lerp(c, d, u.x); // 위쪽
return lerp(lerp_x0, lerp_x1, u.y); // 위아래 보간 -> 최종 값
}
// [ FBM (Fractional Brownian Motion) : 여러 레이어의 Noise 합성 ]
// 입력 좌표 uv에 대해 여러 주파수/진폭의 노이즈를 합쳐 자연스러운 패턴 생성
float FBM(float2 uv)
{
float value = 0.0; // 최종 fbm 값
float amplitude = 0.5; // 초기 진폭
float frequency = 1.0; // 초기 주파수
// 여러 옥타브(Noise 레이어)를 합성
for (int i = 0; i < 4; i++)
{
value += amplitude * Noise(uv * frequency); // 각 레이어 Noise 합치기
frequency *= 2.0; // 다음 레이어는 주파수를 2배로 -> 더 세밀한 패턴
amplitude *= 0.5; // 진폭은 반으로 줄여서 영향력 감소
}
return value;
}
float4 main(PS_INPUT input) : SV_TARGET
{
float2 uv = input.uv; // 현재 픽셀의 UV 좌표 (0~1 범위) : 화면에서 이 픽셀이 어디 위치하는지를 나타냄
//=====================================================================
// 1. 기본 흐름 (전체가 위로 이동하는 큰 움직임)
//=====================================================================
// - advectedUV: 연기가 시간에 따라 이동한 위치
float2 flowDir = float2(0.0, -1.0); // 위쪽 : Y 방향 음수(-)
float2 advectedUV = uv + flowDir * time * 0.1; // time 에 따라 UV를 위쪽으로 이동
//=====================================================================
// 2. 큰 흐름 (연기 덩어리 단위의 느린 흔들림) , 주파수: 0.6
//=====================================================================
// - largeWarp: 연기 전체가 좌우로 휘어지는 "큰 흐름" 역할
float2 largeWarp = float2(
FBM(advectedUV * 0.6 + time * 0.05), // X 방향 왜곡
FBM(advectedUV * 0.6 - time * 0.05) // Y 방향 왜곡
);
//=====================================================================
// 3. 작은 난류 (연기 표면의 잔잔한 요동) , 주파수: 2.0
//=====================================================================
// - smallWarp: 연기 표면의 미세하게 끓는 듯한 느낌
float2 smallWarp = float2(
FBM(advectedUV * 2.0 + time * 0.2),
FBM(advectedUV * 2.0 - time * 0.2)
);
//=====================================================================
// 4. Domain Warping (큰 흐름 + 작은 난류 합성)
//=====================================================================
// - warp: UV 좌표를 얼마나 왜곡할지 결정하는 값
// 큰 흐름은 강하게, 작은 난류는 약하게 섞기
// -> 전체적인 방향성 유지하면서 자연스러운 디테일 추가
float2 warp = largeWarp * 1.2 + smallWarp * 0.4;
//=====================================================================
// 5. 왜곡된 UV로 연기 밀도 계산
//=====================================================================
// - density: 값이 클수록 연기가 진하고, 작을수록 연기가 옅음
// 왜곡된 좌표에 FBM을 다시 적용: 연기의 진하고 옅은 부분(밀도) 생성
float density = FBM(advectedUV + warp);
//=====================================================================
// 6. 위로 갈수록 연기가 사라지는 효과 (높이에 따른 밀도 감쇠)
//=====================================================================
// 약간의 노이즈를 섞어서 위쪽이 균일하게 사라지지 않도록 함
float fadeNoise = FBM(uv * 1.5);
// uv.y가 커질수록(위로 갈수록) 값이 줄어듦 -> smoothstep으로 부드러운 감쇠 곡선 생성
float heightFade = smoothstep(1.0, 0.2, uv.y + fadeNoise * 0.1);
// 최종 밀도에 높이 감쇠 적용
density *= heightFade;
//=====================================================================
// 7. 최종 색상 출력
//=====================================================================
// RGB는 연기 색상 (연한 회색)
// Alpha는 연기 밀도 -> 진할수록 불투명
float4 smokeColor = float4(0.8, 0.8, 0.8, density);
return smokeColor;
}
7. 구현 영상
'D3D' 카테고리의 다른 글
| [그래픽스] Procedural Noise의 이해 : Noise → FBM → Domain Warping (0) | 2025.12.18 |
|---|---|
| [그래픽스] HDR 렌더링 : 렌더타겟, 노출, 톤 매핑 (0) | 2025.12.17 |
| [D3D] IBL 구현 이슈 : IBL Specular가 Roughness에 반응하지 않았던 이유 (0) | 2025.12.16 |
| [그래픽스] IBL (Image Based Lighting) : PBR을 위한 환경 기반 간접광 (0) | 2025.12.10 |
| [그래픽스] PBR(PBR Rendering)에서의 감마 보정 (0) | 2025.12.03 |