https://github.com/sacshadow/3D_ChineseInkPaintingStyleShader/tree/main
GitHub - sacshadow/3D_ChineseInkPaintingStyleShader: An application of 3D Chinese Ink Painting Style shader using Unity
An application of 3D Chinese Ink Painting Style shader using Unity - sacshadow/3D_ChineseInkPaintingStyleShader
github.com

내 게임에 적용하기 위해서 가져왔던 Shader 코드를 분석하고자 한다.
모르고 썼을 때, 정말 답답해서 Shader 공부를 시작하게 된 계기였는데..
드디어, 이 녀석이 무슨 원리로 작동했는지 이해하려고 공부를 해왔다...!!
모든 Shader 코드를 다루진 않고, CIPR_0_Gou_Step_0 셰이더를 다루겠다.
Shader Code 접은 글
Shader "CIPR/CIPR_0_Gou_step_0" {
Properties {
_RimColor ("Rim Color", Color) = (0,1,1,1)
_RimRate ("Rim Rate", Range(0,5)) = 1
_InlineControl("Inline Ctrl{range, step, noiseSize, noise cutoff}", Vector) = (0.1,0.5,1,0.5)
_OutlineColor ("Outline Color", Color) = (0.05,0.05,0.05,1)
_OutlineNoiseFB ("Outline FeiBai", 2D) = "white" {}
_OutlineWidth0 ("Outline width 0", Range(0, 20)) = 1
_OutlineWidth1 ("Outline width 1", Range(0, 20)) = 1
_OutlineNoiseCtrl ("ON Ctrl{size0, cutoff0, size1, cutoff1}", Vector) = (1,5,1,5)
}
SubShader {
//Texture pass
Pass {
Tags { "RenderType"="Opaque"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
float rimLight : TEXCOORD2;
};
sampler2D _OutlineNoiseFB;
float4 _InlineControl;
float4 _OutlineColor;
float4 _RimColor;
float _RimRate;
v2f vert (appdata v) {
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float dis = length(worldPos - _WorldSpaceCameraPos);
float rimLight = clamp(1 - saturate(dot(worldViewDir, worldNormal)),0,1);
rimLight = clamp(pow(rimLight, _RimRate),0,1);
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = (worldPos.xy + worldPos.yz + worldPos.xz) * _InlineControl.z;
o.rimLight = rimLight;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target {
// sample the texture
fixed4 col = 1;
col.rgb = lerp(col, _RimColor, i.rimLight);
fixed4 noise = tex2D(_OutlineNoiseFB, i.uv);
float a = _InlineControl.x;
float b = clamp(1-a,0,1);
col.rgb = lerp(_OutlineColor, col, step((i.rimLight - b)/a + noise * _InlineControl.w, _InlineControl.y));
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
//Outline 0
Pass {
Tags {"RenderType"="Opaque" "IgnoreProjector"="True" }
Cull Front
//Offset -10,0
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _OutlineNoiseFB;
float4 _OutlineColor;
float _OutlineWidth0;
float4 _OutlineNoiseCtrl;
v2f vert (appdata v) {
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float dis = length(worldPos - _WorldSpaceCameraPos);
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float2 extend = normalize(TransformViewToProjection(normal.xy));
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.vertex.xy += extend * _OutlineWidth0 * 0.001 * o.vertex.w;
o.uv = (worldPos.xy + worldPos.yz + worldPos.xz) * _OutlineNoiseCtrl.x;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target {
fixed4 noise = tex2D(_OutlineNoiseFB, i.uv);
clip(noise - _OutlineNoiseCtrl.y/10);
fixed4 col = _OutlineColor;
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
//Outline 1
Pass {
Tags { "RenderType"="Opaque" "IgnoreProjector"="True"}
Cull Front
Offset 20,0
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _OutlineNoiseFB;
float4 _OutlineColor;
float _OutlineWidth0;
float _OutlineWidth1;
float4 _OutlineNoiseCtrl;
v2f vert (appdata v) {
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float dis = length(worldPos - _WorldSpaceCameraPos);
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float2 extend = normalize(TransformViewToProjection(normal.xy));
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.vertex.xy += extend * (_OutlineWidth0 + _OutlineWidth1) * 0.001 * o.vertex.w;
o.uv = (worldPos.xy + worldPos.yz + worldPos.xz) * _OutlineNoiseCtrl.z;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target {
fixed4 noise = tex2D(_OutlineNoiseFB, i.uv);
clip(noise - _OutlineNoiseCtrl.w/10);
fixed4 col = _OutlineColor;
//col = noise;
// apply fog
//UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}
*해당 Shader는 CGPROGRAM으로 되어있다.
- Shader 구조
Shader의 구조부터 살펴보자.
Shader
{
Properties {}
SubShader
{
//Texture Pass
Pass
{
Tags
CGPROGRAM
...
ENDCG
}
//Outline 0
Pass
{
Tags
Cull Front
CGPROGRAM
ENDCG
}
//Outline 1
Pass
{
Tags
Cull Front
Offset 20,0
CGPROGRAM
ENDCG
}
}
}
이 쉐이더는 3개의 Pass로 이루어져 있고,
Textur Pass / Outline Pass 0 / Outline Pass 1 로 구분된다.
- Properties
Properties {
_RimColor ("Rim Color", Color) = (0,1,1,1)
_RimRate ("Rim Rate", Range(0,5)) = 1
_InlineControl("Inline Ctrl{range, step, noiseSize, noise cutoff}", Vector) = (0.1,0.5,1,0.5)
_OutlineColor ("Outline Color", Color) = (0.05,0.05,0.05,1)
_OutlineNoiseFB ("Outline FeiBai", 2D) = "white" {}
_OutlineWidth0 ("Outline width 0", Range(0, 20)) = 1
_OutlineWidth1 ("Outline width 1", Range(0, 20)) = 1
_OutlineNoiseCtrl ("ON Ctrl{size0, cutoff0, size1, cutoff1}", Vector) = (1,5,1,5)
}
익숙한 녀석이 보이는 구나.
이 쉐이더는 우선적으로 Rim Light를 활용하려고 하고 있고
Inline / Outline 으로 구분하여, 오브젝트의 경계 내외 처리를 하려는 걸 알 수 있다
FeiBai 는 중국 붓놀림 기법을 뜻하는데, Noise 역할을 하는 Texture이다.

- Texture Pass
//Texture pass
Pass {
Tags { "RenderType"="Opaque"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
float rimLight : TEXCOORD2;
};
sampler2D _OutlineNoiseFB;
float4 _InlineControl;
float4 _OutlineColor;
float4 _RimColor;
float _RimRate;
v2f vert (appdata v) {
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float dis = length(worldPos - _WorldSpaceCameraPos);
float rimLight = clamp(1 - saturate(dot(worldViewDir, worldNormal)),0,1);
rimLight = clamp(pow(rimLight, _RimRate),0,1);
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = (worldPos.xy + worldPos.yz + worldPos.xz) * _InlineControl.z;
o.rimLight = rimLight;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target {
// sample the texture
fixed4 col = 1;
col.rgb = lerp(col, _RimColor, i.rimLight);
fixed4 noise = tex2D(_OutlineNoiseFB, i.uv);
float a = _InlineControl.x;
float b = clamp(1-a,0,1);
col.rgb = lerp(_OutlineColor, col, step((i.rimLight - b)/a + noise * _InlineControl.w, _InlineControl.y));
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
Texture Pass에서
appdata에 포지션, 노멀, UV 텍스처를, v2f 에 uv, Clip Position, Rim Light 값이 들어간다.
(1) vertex shader
Vert 함수를 보자.
우리는 이미 RimLight의 정확한 동작을 알고 있기 때문에
개발자의 의도만 파악하면 그만이다.
float rimLight = clamp(1 - saturate(dot(worldViewDir, worldNormal)),0,1);
월드 노멀 벡터와 뷰 벡터의 내적이 수직 (물체의 가장자리에 갈 수록) 0에 가깝게 값을 가지며...
1에 대하여 빼면, 반전한다.
그외에는 텍스처링을 하기 위한 v2f 값을 채워주고 있다.
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = (worldPos.xy + worldPos.yz + worldPos.xz) * _InlineControl.z;
/*
(x, y, 0) + (0, y, z) + (x, 0, z) * noiseSize
=> 3D coordinate to 2D UV Texture.
*/
그냥 지나칠 뻔 했는데, UV를 생성할 때, noiseSize 를 하나 곱해주고 있는데
UV 생성 시 Scale을 고려한 파라미터로 이해하면 될 것 이다.
(2) fragment shader
col.rgb = lerp(col, _RimColor, i.rimLight);
앞서 구했던, 오브젝트의 RimLight 값을 Clamp로 0에서 1로 맞춘 이유는
칼라를 구할 때, Lerp에 사용하기 위함이다.
오브젝트가 가지고 있는 색상과 사용자가 지정한 RimColor를 RimLight의 값에 따라 가장자리에서 내부까지 0 ~ 1로 분배한다.
여기서 RimLight 반영이 되었고, 뒤에 후처리가 존재하는데..
// _RimColor ("Rim Color", Color) = (0,1,1,1)
// _OutlineColor ("Outline Color", Color) = (0.05,0.05,0.05,1)
// Inline Ctrl{range, step, noiseSize, noise cutoff} = (0.1,0.5,1,0.5)
fixed4 noise = tex2D(_OutlineNoiseFB, i.uv);
float a = _InlineControl.x;
float b = clamp(1-a,0,1);
col.rgb = lerp(
_OutlineColor,
col,
step((i.rimLight - b)/a + noise * _InlineControl.w, _InlineControl.y)
);
lerp를 한번 더 진행하는 부분은 의아 했는데, 이러면 Outline을 Pass 포함해서 총 3개나 쓴다는 뜻일텐데.. 어쨌든
여기서 재밌는 사실은, lerp를 일종의 Condition으로 사용 했다는 것이다.
Step 함수의 결과 값은 0 아니면 1일테고.
Edge에 해당하는 값 = (i.rimLight - b)/a + noise * _InlineControl.w
In에 해당하는 값 = _InlineControl.y = 0.5f 라는 Threshold를 걸었는데
lerp 효과는 그냥 없다 생각하고. outline 컬러를 내보낼 것이냐, rimLight를 포함한 현재 색상을 내보낼 것이냐를 결정하는 것이다.
rimLight의 값은 vertext에서 0 ~ 1 강제된 값이므로, 가장자리로 갈 수록 (rimLight = 1) threshold에 따라 Outline 컬러 값이 결정된다는 것인데.


위 결과는 Outline Pass 2개를 제외하고 실행한 결과이다.
자체적으로 Outline을 가진 채로 렌더링된다.
- 2개의 Outline Pass들
기본적으로 두 아웃라인 패스들은 Cull Front 가 달려있다.
-appData, v2f
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
-vert
//Outline 0
v2f vert (appdata v) {
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float dis = length(worldPos - _WorldSpaceCameraPos);
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float2 extend = normalize(TransformViewToProjection(normal.xy));
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.vertex.xy += extend * _OutlineWidth0 * 0.001 * o.vertex.w;
o.uv = (worldPos.xy + worldPos.yz + worldPos.xz) * _OutlineNoiseCtrl.x;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
//Outline 1
v2f vert (appdata v) {
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float dis = length(worldPos - _WorldSpaceCameraPos);
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
float2 extend = normalize(TransformViewToProjection(normal.xy));
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.vertex.xy += extend * (_OutlineWidth0 + _OutlineWidth1) * 0.001 * o.vertex.w;
o.uv = (worldPos.xy + worldPos.yz + worldPos.xz) * _OutlineNoiseCtrl.z;
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
이걸 보면서 느낀 개인적인 감상은
이 Shader를 개발한 사람의 코드 습관은 일단 world 관련 정보를 만들어놓고, 사용하던 말던 변수에 할당을 하는 것으로 보인다.
worldViewDir나 worldNormal 같은 값은 전혀 사용하지 않으니 말이다.
Outline 자체가 구현이 그렇게 어렵지 않았는데도, 무언가 코드 길이가 긴 이유가 있었구나.
Vertex Shader의 결과물은 어쨌든 v2f o 이므로, o에 할당되는 값만 보면 된다.
o.vertex는 Clip Space의 픽셀들인데, 바로 다음 줄에서 Vertex를 건드려주고 있다.
우선, extend 부터 보자.
노멀 벡터의 xy (화면과 평행한 방향) 벡터를 clip space로 변환하였고, 파라미터 값만큼 xy평면에 프로젝션된 노멀 방향으로 vertex를 보내주고 있다.
그리고 마지막으로 o.vertex.w 를 곱해주고 있다.
이 부분이 중요한게, 일정 두께를 유지한채로 카메라에서 멀어지면 이 Outline의 크기는 그만큼 작아지게 된다.
Depth 정보를 곱해버려서 카메라 거리와 일정하게 두께를 유지하는 효과가 있다.
-frag
fixed4 frag (v2f i) : SV_Target {
fixed4 noise = tex2D(_OutlineNoiseFB, i.uv);
clip(noise - _OutlineNoiseCtrl.y/10);
fixed4 col = _OutlineColor;
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
Fragment Shader 부분은 두 패스 동일하다.
Clip 함수는 입력값이 0보다 작으면, 해당 픽셀은 버려버리는 함수이다.

이 노이즈 텍스쳐에서 OutlineNoiseCtrl.y/10 보다 작으면, 해당 픽셀은 버려버린다.

Shader를 분석한 느낌.
결국은 노이즈 텍스처가 붓 느낌에 제일 영향을 많이 준거잖아....
결과적으로 따지면 RimLight와 Noise를 먹인 Outline 의 조화를 잘 살린 것 같다.

실제 샘플을 들어가보면, outline와 rimLight로 굉장히 그럴싸하게 만든 것을 확인 할 수 있다.
'Game Dev > Unity Shader' 카테고리의 다른 글
| 명일방주의 눈 밟는 쉐이더 만들기 - Unity Shader (2) | 2026.01.26 |
|---|---|
| Volumetric Fog Shader 리뷰하기 - Unity Shader (0) | 2026.01.11 |
| Unity URP Multi-Pass 사용 with Cel Shading (3) - Unity Shader (0) | 2026.01.05 |
| Cel Shading 실험 (2) - Research (0) | 2026.01.01 |
| 레트로 게임을 스케일링 해주는 xBR 알고리즘 - Unity Shader (1) | 2025.12.30 |