역자의 말
Unity Shader로 구현하는 간단 털 렌더링 – 짧은 털 편
이 글은 Unity URP에서 Shell 기법을 이용해 짧은 털(동물의 잔털, 천의 보풀 등)을 구현하는 과정을 정리한 한국어 번역본입니다. 원문의 구현은 “완성형 퀄리티”라기보다, 아이디어와 파이프라인을 공유하는 데 초점이 맞춰져 있습니다.
저자 : 红烧五花蛆
Unity Shader로 구현하는 간단 털 렌더링 – 짧은 털 편
이 글은 Unity URP에서 Shell 기법을 이용해 짧은 털(동물의 잔털, 천의 보풀 등)을 구현하는 과정을 정리한 한국어 번역·요약본입니다. 원문의 구현은 “완성형 퀄리티”라기보다, 아이디어와 파이프라인을 공유하는 데 초점이 맞춰져 있습니다.
개요
짧은 털은 대개 표면 색과 비슷하고 길이가 매우 짧은 털을 의미합니다. 멀리서 보면 털이 따로 보이기보다, 표면에 붙은 볼륨감 정도로 인식되기 때문에, 긴 털처럼 한 올 한 올 모델링하지 않고 매크로한 볼륨으로 취급하는 경우가 많습니다.
이 글에서는 가장 대표적인 방법인 Shell 기법을 사용합니다.
짧은 털은 “볼륨 있는 표면”으로 다루고, Shell 여러 장을 겹쳐 체적감을 만든다.

Shell 기법이란?
종이 여러 장을 조금씩 띄워서 쌓으면 두께가 생기듯, 기존 메시를 법선 방향으로 여러 번 조금씩 밀어낸 Shell(껍질)들을 겹쳐서 털의 두께를 만드는 방식입니다.
각 Shell은 노이즈 텍스처를 이용해 털이 있을 부분만 남기고 나머지를 clip으로 잘라, 여러 장을 겹쳤을 때 털 덩어리처럼 보이게 합니다.
- 흰색: 털 단면 (살아남는 픽셀)
- 검은색: 잘려나가는 부분
여러 장의 노이즈를 서로 다른 Shell에 배치해 쌓으면, 유리판에 반투명 노이즈를 여러 장 붙여 겹친 것처럼 3D 볼륨감이 생깁니다.
1. Shell 레이어 생성
DrawMeshInstancedIndirect로 여러 번 그리기
Unity의 Graphics.DrawMeshInstancedIndirect를 사용해 같은 메시를 여러 인스턴스로 그리되, 각 인스턴스(=Shell 레이어)의 높이를 달리 줍니다.
// 그릴 메시
Mesh mesh = GetComponent<MeshFilter>().sharedMesh;
// DrawMeshInstancedIndirect 에서 사용할 args 버퍼
ComputeBuffer argsBuffer =
new ComputeBuffer(1, 5 * sizeof(uint), ComputeBufferType.IndirectArguments);
uint[] args = new uint[5];
args[0] = (uint)mesh.GetIndexCount(0); // 인스턴스당 인덱스 수
args[1] = (uint)(shellCount + 1); // 인스턴스 개수 (0 = 바닥 표면 + Shell)
args[2] = (uint)mesh.GetIndexStart(0); // 인덱스 시작 위치
args[3] = (uint)mesh.GetBaseVertex(0); // 베이스 버텍스 오프셋
args[4] = 0; // 첫 인스턴스 시작 인덱스
argsBuffer.SetData(args);
// Bounds 설정 – Shell이 바깥으로 튀어나가니 넉넉히 확장
var bound = mesh.bounds;
bound.Expand(100f);
// mat: 털 전용 머티리얼
Graphics.DrawMeshInstancedIndirect(mesh, 0, mat, bound, argsBuffer);
각 인스턴스가 몇 번째 Shell 레이어인지를 알아야 하므로, C#에서 인덱스를 만들어 ComputeBuffer로 넘기고, Shader에서는 StructuredBuffer<int>로 받습니다.
// C# – 레이어 인덱스 버퍼
ComputeBuffer indexBuffer = new ComputeBuffer(shellCount + 1, sizeof(int));
int[] indices = new int[shellCount + 1];
for (int i = 0; i <= shellCount; ++i)
{
indices[i] = i; // 0 = 바닥 표면, 1~N = Shell
}
indexBuffer.SetData(indices);
mat.SetBuffer("_ShellIndexBuffer", indexBuffer);
// Shader – 레이어 인덱스 수신
StructuredBuffer<int> _ShellIndexBuffer;
// SV_InstanceID 로 현재 인스턴스의 ID 획득
uint instanceID : SV_InstanceID;
int layer = _ShellIndexBuffer[instanceID];
법선 방향으로 Shell 밀어내기
각 Shell 레이어는 법선 방향으로 일정 거리씩 오프셋한다.
- layer == 0 : 원래 표면 (피부/바닥)
- layer >= 1 : 털 Shell
버텍스 셰이더에서 월드 법선을 구한 뒤, HairHeight * layer 만큼 이동시키면 됩니다.
여기서 주의할 점은 DrawMeshInstancedIndirect는 GameObject의 Transform을 자동으로 적용하지 않는다는 것 입니다. C#에서 localToWorldMatrix를 ComputeBuffer나 Material.SetMatrix로 전달해 직접 적용해야 합니다.

2. 다중 노이즈 텍스처 생성과 사용
털의 실루엣과 밀도는 노이즈 텍스처 품질과 개수에 크게 좌우됩니다. 글에서는 32장 정도의 셀룰러 노이즈 텍스처를 사용했습니다.

노이즈 제작은 SD(Stable Diffusion가 아니라 Substance Designer) 등으로 셀룰러 노이즈 노드 2개 정도를 조합해 쉽게 만들 수 있고, 귀찮다면 글의 저자가 제공하는 GitHub 텍스처를 사용할 수 있습니다.

C#에서 Texture2DArray 구성
여러 장의 Texture2D를 받아 Texture2DArray로 합쳐 Shader에 넘깁니다.
public Texture2D[] hairTexs;
private Texture2DArray hairTex;
private void SetTexture2DArray()
{
hairTex = new Texture2DArray(512, 512,
hairTexs.Length,
hairTexs[0].format,
true);
for (int i = 0; i < hairTexs.Length; ++i)
{
Graphics.CopyTexture(hairTexs[i], 0, 0, hairTex, i, 0);
}
// HLSL 에서는 TEXTURE2D_ARRAY 로 선언
mat.SetTexture("_HairTex", hairTex);
}
Shader에서 털 마스크 클리핑
각 Shell 레이어는 자신의 레이어 인덱스를 사용해 배열에서 해당 노이즈를 꺼내고, R 채널을 마스크로 사용해 clip 합니다.
float hairHeight = SAMPLE_TEXTURE2D_ARRAY(_HairTex, sampler_HairTex,
f.uv, f.layer - 1).r;
clip(hairHeight - _HairMaskThreshold);
_HairMaskThreshold를 크게 올리면 털이 듬성듬성해져 보기 좋지 않으므로, 보통 작은 값으로 미세하게 조절합니다.

3. 투명도 처리 (Shell별 알파 조절)
실제 털은 겉으로 갈수록 가늘고 투명해집니다. 이를 반영하기 위해 Shell 레이어가 높아질수록 알파를 줄입니다.
// layer: 현재 Shell 인덱스, _MaxShell: 최대 Shell 수
float layerFactor = (float)f.layer / (float)_MaxShell;
// 가장 외곽 Shell 일수록 알파가 작아짐
return half4(finalColor, 1.0 - layerFactor);
렌더 큐는 Transparent로 변경해 투명 블렌딩을 사용합니다.

4. 자가 음영(Self Occlusion)
실제 털은 안쪽으로 갈수록 어두워지는 자가 음영이 있습니다. 복잡한 전역 조명 대신, Shell 레이어에 따라 색을 점점 어둡게 섞는 근사로 처리합니다.
// _OcclusionColor: 털 내부로 갈수록 섞일 음영 색상
float3 occlusionColor = lerp(_OcclusionColor.rgb,
1.0,
pow(abs(layerFactor), _OcclusionPower));

5. 림라이트 (Edge Light)
겉부분 털은 역광에서 강하게 빛나 보이는 Rim 효과가 있습니다. N·V 기반 단순 Rim 또는 Fresnel을 사용해 구현할 수 있습니다.
float NdotV = max(0, dot(f.wNormal, viewDir));
// 단순 Rim
float rim = pow(saturate(1 - NdotV), _RimPower)
* lerp(0.2, 1.0, layerFactor * layerFactor);
float3 rimColor = _RimColor.rgb * rim * _RimStrength;
// 또는 Fresnel 기반 Rim
float rimFresnel = _RimFresnel
+ (1 - _RimFresnel) * pow(1 - NdotV, 5);
float3 rimColor2 = _RimColor.rgb * rimFresnel * _RimStrength;

좌:직접 NdotV 사용 / 우:Fresnel
6. 근사 SSS (Subsurface Scattering)
짧은 털이나 피부는 빛이 어느 정도 내부로 들어갔다가 나오는 SSS 효과가 있습니다. 글에서는 Alan Zucconi의 Fast SSS 공식을 간단히 적용합니다.

float fss = pow(saturate(dot(viewDir,
-(lightDir + f.wNormal * _FSSDirCorrect))),
_FSSPower) * _FSSScale;
빛을 정면에서 받을 때 밝고, 특히 붉은 톤(피부) 등에 적합합니다.

7. 기본 조명: Diffuse & Specular
Diffuse
- 일반적인 Lambert / Half-Lambert
- 또는 Kajiya–Kay 모델의 Diffuse
짧은 털에서는 전통적인 Lambert 계열만으로도 충분하다고 판단합니다. Kajiya–Kay는 본래 긴 털용 특화 모델이기 때문입니다.

좌:Half-Lambert / :Kajiya–Kay Diffuse
Specular (Kajiya–Kay 하이라이트)
Kajiya–Kay의 장점 중 하나는 Tangent를 Shift하여 하이라이트 위치를 제어할 수 있다는 점입니다.
real3 ShiftTangent(real3 T, real3 N, real shift)
{
return normalize(T + N * shift);
}
그런 다음 변위된 접선을 사용하여 하이라이트를 계산합니다:
float TdotH = dot(wTangent, halfDir);
float sinTH = sqrt(1.0 - TdotH * TdotH);
// _SpecColor.a 로 스페큘러 강도 조절
float3 specColor = pow(sinTH, _SpecGloss) *
_SpecColor.rgb * mainLight.color *
(f.layer / (float)_MaxShell) * _SpecColor.a;

이때 **역광 측면에서 이상한 하이라이트(새는 빛)**가 보이는데, 이후 그림자 수신 단계에서 해결합니다.

8. 그림자 – 투영과 수신
8‑1. 그림자 투영 (ShadowCaster Pass)
URP의 기본 ShadowCaster 패스를 사용합니다. 중요한 점은 메인 Pass에서 했던 버텍스 오프셋(털 Shell 높이)을 ShadowCaster에서도 똑같이 해야 한다는 것입니다. 그렇지 않으면 그림자가 찌그러지거나 위치가 맞지 않습니다.
Pass
{
Tags { "LightMode" = "ShadowCaster" }
ZWrite On
HLSLPROGRAM
#pragma vertex vertShadow
#pragma fragment fragShadow
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"
TEXTURE2D_ARRAY(_HairTex);
SAMPLER(sampler_HairTex);
struct Attributes
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 texcoord : TEXCOORD0;
};
struct Varyings
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float layer : TEXCOORD1;
};
Varyings vertShadow(Attributes v, uint instanceID : SV_InstanceID)
{
Varyings o;
int layer = _ShellIndexBuffer[instanceID];
float4x4 localToWorld = _MatrixBuffer[0];
float3 wPos = mul(localToWorld, v.vertex).xyz;
float3 wNormal = normalize(mul((float3x3)localToWorld, v.normal));
if (layer != 0)
{
wPos += _HairHeight * wNormal * (float)layer;
}
o.pos = TransformWorldToHClip(wPos);
o.uv = v.texcoord.xy;
o.layer = layer;
return o;
}
half4 fragShadow(Varyings i) : SV_Target
{
if (i.layer != 0)
{
float hairHeight = SAMPLE_TEXTURE2D_ARRAY(_HairTex, sampler_HairTex,
i.uv, i.layer - 1).r;
clip(hairHeight - _HairMaskThreshold);
}
return 0;
}
ENDHLSL
}
현재 구현은 모든 Shell이 그림자를 투영하기 때문에 다소 무겁습니다. 일부 레이어만 그림자에 참여시키는 최적화를 고려할 수 있습니다.
또한 특정 시점에서 그림자가 일부 사라지는 문제가 있을 수 있는데, 이는 URP의 캐스케이드 섀도 설정(거리, 분할, 깊이 bias 등)을 조정해 어느 정도 완화할 수 있습니다.

8‑2. 그림자 수신 (Self-Shadow 제거와 누설 방지)
먼저, URP 그림자를 켜기 위해 다음 매크로들을 활성화합니다.
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _SHADOWS_SOFT
기본 URP 실시간 그림자 수신 절차를 따르되, 털이 자기 자신을 너무 많이 가리는 자가 음영을 줄이기 위해 법선 방향으로 살짝 offset한 위치에서 섀도 샘플링을 합니다.
float3 offsetWorldPos = f.wPos + f.wNormal * _DepthBias;
float4 shadowCoord = TransformWorldToShadowCoord(offsetWorldPos);
float shadowAtten = MainLightRealtimeShadow(shadowCoord);
// 살짝 밝게 만들기 위한 보정 값
float addShadowAtten = shadowAtten + _ShadowAdd;
finalColor = (diffuseColor + rimColor + fss + specColor)
* occlusionColor * addShadowAtten;

상:자가 음영/딱딱한 그림자 / 하:부드러운 그림자 보정
이 상태에서는 아까 말한 **역광 하이라이트 누설(빛샘)**이 남아있습니다. 이를 해결하기 위해 Specular만 원래 shadow 값(밝게 보정 안 된 값)에 곱해 따로 처리합니다.
finalColor = (diffuseColor + rimColor + fss)
* occlusionColor * addShadowAtten
+ specColor * occlusionColor * shadowAtten;

8‑3. 특이한 그림자 현상
글에서는 실험 중 같은 물체 그림자가 앞면·뒷면·바닥에 3중으로 보이다가, 투영 패스를 켠 뒤에야 자연스러워지는 기묘한 현상을 관찰했다고 언급합니다.

필자는 URP 그림자 시스템이 “투영 후 앞쪽에 가려서” 실제로는 하나만 보이도록 설계되어 있을 가능성을 추측하지만, 명확한 원인은 알 수 없다고 적고 있습니다.

적용 후
9. 노이즈 텍스처 Tiling & Offset
C#에서 텍스처 배열을 사용할 때 Inspector의 tiling/offset을 직접 건드리기 어렵기 때문에, Shader에서 별도의 털 전용 UV (hairUV) 를 만들어 밀도와 위치를 제어합니다.
// 머티리얼에서 조절할 벡터 (xy = tiling, zw = offset)
_HairTillingScale("HairTillingScale", Vector) = (1, 1, 0, 0)
// v.texcoord.xy 는 기본 UV
// hairUV 는 털 노이즈 전용 UV
float2 uv = v.texcoord.xy;
float2 hairUV = uv * _HairTillingScale.xy + _[HairTillingScale.zw](<http://HairTillingScale.zw>);
// FlowMap 으로 추가 오프셋을 줄 때는 hairUV 기준
float2 offsetUV = hairUV
+ SAMPLE_TEXTURE2D(_FlowmapTex, sampler_FlowmapTex, hairUV).rg
* 0.1 * layerFactor * layerFactor * _FlowmapStrength.xy;
// 털 노이즈는 offsetUV 로 샘플
// 표면 텍스처는 원본 uv 로 샘플
float3 baseColor = SAMPLE_TEXTURE2D(_SurfaceTex, sampler_SurfaceTex, uv).rgb;
이렇게 하면 표면 텍스처의 스케일은 유지한 채, 털의 밀도와 방향만 독립적으로 조절할 수 있습니다.
10. FlowMap으로 털 방향 제어
FlowMap(흐름 맵)은 각 지점의 방향 벡터를 RG 채널에 인코딩한 텍스처로, 털이 어느 방향으로 빗겨 있는지 표현하는 데 사용할 수 있습니다.

流向图
기본 UV에서 FlowMap RGB(보통 RG만 사용)를 읽어 살짝 오프셋을 주면, 털이 같은 노이즈를 쓰더라도 방향성 있는 패턴을 가지게 됩니다.
float2 offsetUV = f.uv
+ SAMPLE_TEXTURE2D(_FlowmapTex, sampler_FlowmapTex, f.uv).rg
* 0.1 * layerFactor * layerFactor * _FlowmapStrength.xy;
11. 외력 시뮬레이션 – 중력, 이동, 바람
짧은 털이라도 중력, 캐릭터 이동, 바람의 영향을 받습니다. 글에서는 모두 버텍스 셰이더 단계에서 월드 포지션을 조정하는 방식으로 처리합니다.
11‑1. 중력
간단히 말해, Shell 레이어가 높을수록 더 많이 아래로 당겨줍니다.
wPos.y -= _GravityFactor * layerFactor * layerFactor;

11‑2. 이동에 따른 공기 저항
물체가 빠르게 이동하면, 털은 이동 방향 반대로 눕는 힘을 받습니다.
C#에서 매 프레임 위치 차이를 통해 속도를 구한 뒤, 그 방향과 크기를 Shader에 전달합니다.
private Vector3 lastPosition, currentPosition;
private Vector3 velocity = [Vector3.zero](<http://Vector3.zero>);
private Vector3 forceDir = [Vector3.zero](<http://Vector3.zero>);
private float forcePower = 0f;
private bool lastInit = false;
private float smooth = 5f;
void GetMoveForce()
{
currentPosition = transform.position;
if (lastInit)
{
Vector3 delta = (currentPosition - lastPosition) / Time.deltaTime;
velocity = Vector3.Lerp(velocity, delta, Time.deltaTime * smooth);
forceDir = velocity.normalized;
forcePower = velocity.magnitude * 0.01f;
}
else
{
lastPosition = currentPosition;
lastInit = true;
}
mat.SetVector("_MoveForceDir", forceDir);
mat.SetFloat ("_MoveForcePower", forcePower);
lastPosition = currentPosition;
}
// Shder
float3 baseOffset = _MoveForceDir * _MoveForcePower * 10.0
* _MoveForceFactor * layerFactor * layerFactor;
wPos -= baseOffset;

11‑3. 바람
주기적인 Sine 함수를 사용해 바람에 흔들리는 효과를 만듭니다.
float3 FurWind(float3 worldPos,
float3 worldNormal,
float windSpeed,
float windStrength,
float3 windDir,
float windNormalOffsetFactor)
{
float phase = dot(worldPos.xz, float2(0.1, 0.1));
float osc = sin(_Time.y * windSpeed + phase);
float3 windOffset = windDir * osc * windStrength;
float3 normalOffset = worldNormal * osc * windNormalOffsetFactor * windStrength;
return windOffset + normalOffset;
}
// 다음은 정점의 변위입니다:
float3 windForce = FurWind(wPos, wNormal,
_WindSpeed, _WindStrength,
_WindDir, _WindNormalOffsetFactor)
* layerFactor * layerFactor;
wPos -= windForce;

12. 바닥 표면(스킨)과 털 레이어 분리 렌더링
짧은 털 재질에서는 **가장 아래층(피부/천 표면)**과 그 위에 쌓인 털 Shell을 서로 다르게 처리합니다.
- layer == 0 : 표면 텍스처만 사용하는 일반 표면 렌더링
- layer >= 1 : 위에서 설명한 모든 털 관련 효과를 적용
if (f.layer == 0)
{
// 바닥 표면 렌더링
finalColor = SAMPLE_TEXTURE2D(_SurfaceTex, sampler_SurfaceTex, f.uv).rgb;
}
else
{
// 털 렌더링
...
}
이 때문에 Shell 수가 32라면, 실제로는 총 33번 인스턴싱을 하게 됩니다. (argsBuffer 두 번째 값에 shellCount + 1을 넣었던 이유.)
13. 남아 있는 문제들
13‑1. 근거리 역광 시 밝은 원형 반점
그림자 수신과 하이라이트가 섞이는 구간에서 작은 원형 밝은 반점이 생기는 문제가 있습니다.

圆亮斑
저자는 이 현상이 Specular + ShadowOffset 조합과 관련이 있는 것 같다고 추측하고, _DepthBias를 조정하면 위치가 바뀌는 것을 관찰했지만 아직 명확한 해법은 찾지 못했다고 적습니다.
14. 마치며
이 구현은 “보기 좋은 털 셰이더”라기보다는 Shell 기반 짧은 털 파이프라인을 처음부터 끝까지 만들어 본 레퍼런스에 가깝습니다.
가장 힘든 부분은 품질 좋은 노이즈 텍스처 세트 제작이었고, 결국 직접 만들게 되었다고 합니다. 완성 이미지는 저자 표현대로 “그냥 구현했다 수준”이지만, Shell‑기반 털 렌더링의 흐름을 배우기엔 좋은 예시입니다.
원본 코드는 저자의 GitHub 저장소에서 내려받을 수 있습니다.
https://github.com/RedShaoWuHuaQu/UnityShader
이 글에서 얻을 수 있는 것 (요약)
이 글은 Unity URP에서 Shell 기법을 이용해 짧은 털을 구현하는 풀 파이프라인 튜토리얼입니다. DrawMeshInstancedIndirect로 Shell 레이어를 만들고, Texture2DArray 노이즈로 털 마스크를 구성하며, 투명도·자가 음영·림라이트·SSS·Kajiya–Kay 스펙을 더해 짧은 털 특유의 볼륨과 하이라이트를 구현합니다. 또한 ShadowCaster/수신 최적화, FlowMap으로 털 방향 제어, 중력·이동·바람 등 외력 대응까지 다루어, 단일 글로 “짧은 털 셰이더”의 설계와 구현 흐름을 한 번에 정리해 줍니다.
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| 게임 Coder 전용 모델 강화 학습 WIP (0) | 2025.12.30 |
|---|---|
| [발표 번역]SIGGRAPH 2025 HypeHype의 랜덤 블록 조명 기술 (1) | 2025.12.07 |
| [번역] Cartoon Rendering Colouring Part 1: Forward and Deferred Mixing Shading Techniques (0) | 2025.11.28 |
| [번역] 클립맵 기반 정적 섀도우맵 (0) | 2025.11.26 |
| [발표 번역] Finding Harmony in Anime Style and Physically Based Rendering (0) | 2025.11.19 |