저자: PTSXDWD
배우고 때때로 익히면 또한 기쁘지 아니한가? —— 공자 《논어·학이》
머리말
최근에 쓴 글들을 뒤져 보니, 요즘 글은 거의 다 어떤 기능의 사용법 위주였다. 예를 들면 RenderFeature 사용법, SP、SD Shader 작성법 같은 것들이다. 생각해 보니 그래도 TA의 초심으로 돌아가, 효과 자체를 중심으로 한 글을 좀 더 써야겠다 싶었다. 마침 원신에 공중 신전 신규 맵이 업데이트되었고, 그 안에 있는 공집행관 피규어 전시장 효과가 꽤 재미있어 보였다.

원신의 공중 신전 전시장
프레임 캡처로 한번 뜯어본 다음, Unity에서 복각해 보았다. 복각 효과는 아래와 같다.

—————————————————————————————————————
전시장 프레임
먼저 전시장 프레임부터 보자. 이 부분은 원신에서는 Deferred Rendering을 사용하고 있었지만, 나는 굳이 Deferred로 바꾸지는 않고 그대로 Forward Rendering을 사용했다.

RenderDoc
머티리얼은 그냥 Unity 기본 Lit Shader를 사용했다. 텍스처를 넣고 대충 효과만 조금 조정했다.

Unity 안의 전시장 프레임
이 프레임은 이번 효과의 핵심이 아니므로, 특별히 커스텀할 부분은 많지 않다. 이 정도면 충분하다.
—————————————————————————————————————
전시장 배경
이제 전시장 배경을 보자. 배경 효과 구현은 사실상 CubeMap 한 장을 샘플링하는 것이다.

CubeMap
프레임 캡처를 보면, 렌더링되는 모델은 전시장과 거의 같은 길이, 폭, 높이를 가진 정육면체에 가깝다.

RenderDoc

RenderDoc
그다음 정면을 컬링하고 뒷면만 렌더링한다.

RenderDoc
이 부분의 구현은 사실 매우 간단하다. 그냥 AI에게 Shader 하나 써 달라고 하면 된다. 원신 쪽 렌더링도 반투명 Forward Rendering을 타고 있으니, 우리도 똑같이 하면 된다.
Shader "Shader/ZhanGui_BoLi"
{
Properties
{
[Header(CubeMap)]
_CubeMap("CubeMap", Cube) = "white" {}
_CubeMapIntensity("CubeMap亮度", Range(0, 5)) = 1.0
}
SubShader
{
Tags
{
"LightMode" = "UniversalForward"
"RenderType" = "Transparent"
"Queue" = "Transparent"
}
// 전시장 배경 pass
Pass
{
Name "CubeMap"
Tags
{
"LightMode" = "UniversalForward"
"RenderType" = "Transparent"
"Queue" = "Transparent"
}
ZWrite Off
Cull Front
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float _CubeMapIntensity;
float _CubeMapScale;
CBUFFER_END
TEXTURECUBE(_CubeMap);
SAMPLER(sampler_CubeMap);
struct Attributes
{
float4 positionOS : POSITION;
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float3 positionWS : TEXCOORD0;
};
Varyings vert(Attributes input)
{
Varyings output;
output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
output.positionWS = TransformObjectToWorld(input.positionOS.xyz);
return output;
}
half4 frag(Varyings input) : SV_Target
{
float3 rayDirWS = normalize(input.positionWS - _WorldSpaceCameraPos);
// View Space로 변환해 XY를 스케일링한다. (View Space XY = 화면 수평/수직, 늘어나지 않음)
float3 viewDirVS = mul((float3x3)UNITY_MATRIX_V, rayDirWS);
float3 scaledVS = normalize(float3(viewDirVS.xy, viewDirVS.z));
float3 scaledWS = mul((float3x3)UNITY_MATRIX_I_V, scaledVS);
half4 cubeColor = SAMPLE_TEXTURECUBE(_CubeMap, sampler_CubeMap, scaledWS);
cubeColor.rgb *= _CubeMapIntensity;
return cubeColor;
}
ENDHLSL
}
}
}
그다음 CubeMap을 넣어 주면 바로 효과를 볼 수 있다.

원신 쪽은 가장자리에 약간 Fresnel 효과가 있는 것 같지만, 여기서는 굳이 만들지 않았다.

원신 공중 신전 전시장
—————————————————————————————————————
전시장 유리
다음은 전시장 유리다. 유리는 사실 이 정육면체의 정면을 렌더링해서, 앞에서 렌더링한 배경 위에 덧씌우는 것이다.

RenderDoc

RenderDoc
여기서 유리가 렌더링되며 만들어내는 핵심 효과는 바로 이 가장자리 발광 효과다.

RenderDoc
처음에는 마스크 텍스처를 샘플링하는 줄 알았다. 그런데 프레임 캡처를 해 보니 관련 텍스처가 보이지 않았다. AI로 Shader를 한번 디컴파일해 보니, 아마 Shader 안에서 직접 계산한 Fresnel 비슷한 효과일 가능성이 커 보였다. 다만 내가 간단한 Fresnel로 한번 만들어 보니 결과가 너무 못생겼다.

Fresnel 효과
복잡한 계산은 또 하고 싶지 않았다. 그래서 Fresnel 계산은 포기하고, 그냥 마스크 텍스처를 샘플링해서 이 효과를 구현하기로 했다.
먼저 이 정육면체의 UV를 다시 펴야 한다. 이론적으로는 정육면체의 각 면이 삼각형 두 개면 충분할 텐데, 왜 원신의 이 정육면체는 이렇게 많은 면을 가지고 있는지 모르겠다.

RenderDoc
emmm, 미호요가 이렇게 한 데에는 분명 그들만의 깊은 뜻이 있겠지. (웃음)
나는 Maya에서 모델의 불필요한 선을 모두 지운 다음, UV를 다시 폈다. UV는 전체 UV 영역을 꽉 채우도록 정규화했다.

Maya
그다음 SD로 가져가 마스크를 만들었다. 마스크 자체는 아주 간단해서, 노드 몇 개면 끝난다.

SD
작업한 것들을 모두 Unity로 가져온 뒤, 유리 Shader를 작성하기 시작했다.
기존 배경 Shader 뒤에 Pass를 하나 추가해서 유리를 렌더링한다. URP를 조금이라도 만져 봤다면 알겠지만, URP의 Multi-Pass 렌더링 로직은 Built-in Pipeline과 다르다. URP는 LightMode를 사용해 Multi-Pass를 렌더링하며, Unity가 기본 제공하는 LightMode는 몇 개뿐이다. 그 외의 LightMode는 RenderFeature를 통해 렌더링해야 한다. 이 부분은 예전에 쓴 글을 참고하면 된다.
【URP】Unity 커스텀 RendererFeatures

하지만 여기서는 Pass가 많지 않으므로, Unity가 제공하는 LightMode만 사용해도 충분하다.

Unity가 제공하는 LightMode
첫 번째 배경 Pass는 UniversalForward를 사용했으니, 이번 Pass는 UniversalForwardOnly로 렌더링한다.
// 유리 Pass
Pass
{
Name "Glass"
Tags
{
"LightMode" = "UniversalForwardOnly"
"RenderType" = "Transparent"
"Queue" = "Transparent"
}
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float _GlassIntensity;
float _GlassPow;
float4 _GlassColor;
CBUFFER_END
TEXTURE2D(_GlassTex);
SAMPLER(sampler_GlassTex);
struct appdata
{
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv0 : TEXCOORD0;
};
v2f vert(appdata v)
{
v2f o;
o.pos = TransformObjectToHClip(v.vertex.xyz);
o.uv0 = v.texcoord;
return o;
}
half4 frag(v2f i) : SV_Target
{
float4 col = SAMPLE_TEXTURE2D(_GlassTex, sampler_GlassTex, i.uv0);
col.rgb = pow(col.rgb, _GlassPow) * _GlassColor.rgb;
return half4(col.rgb, col.r * _GlassIntensity);
}
ENDHLSL
}
이 유리 효과는 배경 위에 겹쳐져야 하므로, 투명 블렌딩을 켜는 것에 주의해야 한다.
Blend SrcAlpha OneMinusSrcAlpha
패널 파라미터도 함께 넣어 준다.
Properties
{
[Header(CubeMap)]
_CubeMap("CubeMap", Cube) = "white" {}
_CubeMapIntensity("CubeMap亮度", Range(0, 5)) = 1.0
_CubeMapScale("CubeMap缩放", Range(0, 2)) = 1
[Header(Glass)]
_GlassTex("玻璃遮罩", 2D) = "white" {}
[HDR]_GlassColor("玻璃遮罩颜色", Color) = (1, 1, 1, 1)
_GlassIntensity("玻璃遮罩透明度", Range(0, 5)) = 1.0
_GlassPow("玻璃遮罩Pow", Range(0.1, 5)) = 1.0
}
그다음 우리가 그린 유리 마스크를 넣고, 파라미터를 조금 조정하면 효과가 나온다.

전시장 유리
FrameDebugger를 보면, 먼저 Opaque Queue에서 전시장 프레임이 렌더링되는 것을 확인할 수 있다.

FrameDebugger
그다음 전시장 내부 배경이 렌더링된다.

FrameDebugger
마지막으로 유리 마스크가 렌더링된다.

FrameDebugger
OK, 기본 효과는 이제 거의 나왔다.
—————————————————————————————————————
스크린 왜곡
마지막으로 유리 굴절과 비슷한 이 스크린 왜곡 효과를 보자. 개인적으로는 이번 효과에서 가장 재미있는 부분이라고 생각한다.

원신은 Opaque Object 렌더링이 끝난 뒤, 유리에 사용할 Normal을 Fullscreen RT 한 장에 렌더링한다.

RenderDoc
마지막 후처리 단계에서 다시 이 RT를 샘플링하고, UV를 오프셋해 스크린 왜곡 효과를 구현한다.

RenderDoc
스크린 왜곡에 필요한 화면 샘플링을 후처리 단계로 몰아넣으면, 왜곡 머티리얼마다 Fullscreen Texture를 샘플링해야 하는 횟수를 줄일 수 있다. 이것은 성능 최적화 포인트다.
이제 렌더링 Queue 안에 Pass 하나를 삽입해, 왜곡 정보를 RT에 렌더링해야 한다. 이 Pass는 RendererFeature를 사용해서 구현해야 한다.
스크린 왜곡 RenderFeature
RendererFeature 관련 튜토리얼은 예전에 쓴 글을 참고하면 된다.
【URP】Unity 커스텀 RendererFeatures

먼저 RendererFeature를 하나 만들고, 이름을 ScreenWarpRenderFeature로 짓는다. 열어 보면 대충 이런 형태다.

ScreenWarpRenderFeature
코드를 조금 정리해서, 필요 없는 주석을 삭제한다.

ScreenWarpRenderFeature
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
//————————————————————————————————————————————————————————— ScreenWarpRenderFeature ——————————————————————————————————————————————————————————————————————————————————————
public class ScreenWarpRenderFeature : ScriptableRendererFeature
{
ScreenWarpRenderPass m_ScriptablePass; // ScreenWarpRenderPass
// 초기화 함수
public override void Create()
{
m_ScriptablePass.renderPassEvent = RenderPassEvent.AfterRenderingOpaques; // RenderFeature 삽입 시점
}
// Pass 주입 함수
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(m_ScriptablePass); // 렌더링 Queue에 추가
}
}
//——————————————————————————————————————————————————————————— ScreenWarpRenderPass ——————————————————————————————————————————————————————————————————————————————————————
class ScreenWarpRenderPass : ScriptableRenderPass
{
// 렌더링 실행 준비 단계
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
}
// 핵심 렌더링 이벤트
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
}
// 렌더링 실행 종료 단계
public override void OnCameraCleanup(CommandBuffer cmd)
{
}
}
이제 우리의 로직을 작성해 보자. 먼저 Inspector 패널에 필요한 파라미터를 적어 준다.

ScreenWarpRenderFeature
//————————————————————————————————————————————————————————— 패널 파라미터 ——————————————————————————————————————————————————————————————————————————————————
[System.Serializable]
public class Settings
{
[Header("渲染设置")]
public string lightModeTag = "ScreenWarp"; // LightMode
public string warpTextureName = "_ScreenWarpTex"; // RT 이름
public RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingOpaques; // Render Pass 삽입 시점
public LayerMask layerMask = -1; // 렌더링 Layer 필터
[Header("纹理设置")]
[Range(0.1f, 2f)] public float resolutionScale = 1.0f; // RT 해상도
public RenderTextureFormat format = RenderTextureFormat.ARGBHalf; // RT 포맷
public FilterMode filterMode = FilterMode.Bilinear; // RT 필터 모드
}
그다음 ScreenWarpRenderFeature에서 패널 파라미터 클래스를 인스턴스화하고, ScreenWarpRenderPass로 넘겨 준다.

ScreenWarpRenderFeature
//————————————————————————————————————————————————————————— ScreenWarpRenderFeature ——————————————————————————————————————————————————————————————————————————————————————
public class ScreenWarpRenderFeature : ScriptableRendererFeature
{
public Settings passSettings = new Settings(); // 패널 파라미터 인스턴스 생성
ScreenWarpRenderPass m_ScriptablePass; // ScreenWarpRenderPass
// 초기화 함수
public override void Create()
{
m_ScriptablePass = new ScreenWarpRenderPass(passSettings); // RenderPass 인스턴스 생성
m_ScriptablePass.renderPassEvent = passSettings.renderPassEvent; // RenderPass 삽입 시점
}
// Pass 주입 함수
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
// 카메라에서 후처리를 켜지 않았다면 바로 return
if (!renderingData.cameraData.postProcessEnabled)
return;
// Preview Camera 필터링
if (renderingData.cameraData.renderType != CameraRenderType.Base)
return;
// 렌더링 Queue에 추가
renderer.EnqueuePass(m_ScriptablePass);
}
// RendererFeature 제거 함수
protected override void Dispose(bool disposing)
{
// RenderPass 해제
if (disposing)
{
m_ScriptablePass?.Dispose();
}
}
}
마지막은 ScreenWarpRenderPass다.

ScreenWarpRenderFeature
//————————————————————————————————————————————————————————— ScreenWarpRenderFeature ——————————————————————————————————————————————————————————————————————————————————————
public class ScreenWarpRenderFeature : ScriptableRendererFeature
{
public Settings passSettings = new Settings(); // 패널 파라미터 인스턴스 생성
ScreenWarpRenderPass m_ScriptablePass; // ScreenWarpRenderPass
// 초기화 함수
public override void Create()
{
m_ScriptablePass = new ScreenWarpRenderPass(passSettings); // RenderPass 인스턴스 생성
m_ScriptablePass.renderPassEvent = passSettings.renderPassEvent; // RenderPass 삽입 시점
}
// Pass 주입 함수
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
// 카메라에서 후처리를 켜지 않았다면 바로 return
if (!renderingData.cameraData.postProcessEnabled)
return;
// Preview Camera 필터링
if (renderingData.cameraData.renderType != CameraRenderType.Base)
return;
// 렌더링 Queue에 추가
renderer.EnqueuePass(m_ScriptablePass);
}
// RendererFeature 제거 함수
protected override void Dispose(bool disposing)
{
// RenderPass 해제
if (disposing)
{
m_ScriptablePass?.Dispose();
}
}
}
//——————————————————————————————————————————————————————————— ScreenWarpRenderPass ——————————————————————————————————————————————————————————————————————————————————————
class ScreenWarpRenderPass : ScriptableRenderPass
{
Settings _settings; // 패널 파라미터
ShaderTagId _shaderTag; // LightMode
int _warpTexId; // Shader Property int key
RTHandle _warpTexture; // 왜곡 벡터 RT
FilteringSettings m_FilteringSettings; // 렌더링 Layer 필터
// 생성자
public ScreenWarpRenderPass(Settings settings)
{
_settings = settings; // 패널 파라미터
_shaderTag = new ShaderTagId(settings.lightModeTag); // LightMode
_warpTexId = Shader.PropertyToID(settings.warpTextureName); // 문자열을 int key로 매핑
m_FilteringSettings = new FilteringSettings(RenderQueueRange.all, settings.layerMask); // 렌더링 Layer 필터
}
// 렌더링 실행 준비 단계
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
var desc = renderingData.cameraData.cameraTargetDescriptor; // 카메라 해상도 가져오기
desc.width = Mathf.RoundToInt(desc.width * _settings.resolutionScale); // RT 해상도 스케일
desc.height = Mathf.RoundToInt(desc.height * _settings.resolutionScale); // RT 해상도 스케일
desc.colorFormat = _settings.format; // 컬러 포맷 설정
desc.depthBufferBits = 0; // Depth Buffer 끄기
desc.msaaSamples = 1; // MSAA 끄기
desc.sRGB = false; // sRGB 끄기
// RT 할당
RenderingUtils.ReAllocateIfNeeded(
ref _warpTexture,
desc,
_settings.filterMode,
wrapMode: TextureWrapMode.Clamp,
name: _settings.warpTextureName
);
// 왜곡 RT를 현재 Pass의 출력 대상으로 설정
ConfigureTarget(_warpTexture);
// RT를 중립값으로 클리어: RG(0.5 = 오프셋 없음), A(0 = 마스크 없음)
ConfigureClear(ClearFlag.Color, new Color(0.5f, 0.5f, 0f, 0f));
}
// 핵심 렌더링 이벤트
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
// Drawing Settings 생성
var drawSettings = CreateDrawingSettings(_shaderTag, ref renderingData, SortingCriteria.CommonOpaque);
// Command Buffer 가져오기
var cmd = CommandBufferPool.Get("Screen Warp");
// Profiling Scope 열기
using (new ProfilingScope(cmd, new ProfilingSampler("Screen Warp")))
{
context.ExecuteCommandBuffer(cmd); // Command Buffer 명령 실행
cmd.Clear(); // Command Buffer 비우기
// Pass 그리기
context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref m_FilteringSettings);
// 후처리에서 사용할 전역 텍스처 설정
cmd.SetGlobalTexture(_warpTexId, _warpTexture);
}
context.ExecuteCommandBuffer(cmd); // Command Buffer 명령 실행
CommandBufferPool.Release(cmd); // Command Buffer 해제
}
// RT 해제
public void Dispose()
{
_warpTexture?.Release();
}
}
작성이 끝나면, 만든 RenderFeature를 렌더링 파이프라인에 추가한다.

RenderFeature 추가
스크린 왜곡 Shader
이제 Shader를 작성한다. 이전의 배경 Shader에 Pass를 하나 추가하고, LightMode는 앞에서 RenderFeature에 정의한 ScreenWarp를 사용한다.
원신의 Normal RT에서는 A Channel에 마스크가 저장되어 있었다. 어떤 영역을 왜곡해야 하고, 어떤 영역을 왜곡하지 말아야 하는지 구분하기 위한 용도다.

RenderDoc
그래서 우리도 Depth를 사용해 마스크를 계산하고, RT의 A Channel에 출력한다.
// 스크린 왜곡 Pass
Pass
{
Name "ScreenWarp"
Tags
{
"LightMode" = "ScreenWarp"
"RenderType" = "Transparent"
"Queue" = "Transparent"
}
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float _NormalStrength;
float4 _GlassNormal_ST;
CBUFFER_END
TEXTURE2D(_GlassNormal);
SAMPLER(sampler_GlassNormal);
TEXTURE2D(_CameraDepthTexture); // Camera Depth Texture
SAMPLER(sampler_CameraDepthTexture);
struct appdata
{
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv0 : TEXCOORD0;
float4 screenPos : TEXCOORD1;
};
v2f vert(appdata v)
{
v2f o;
o.pos = TransformObjectToHClip(v.vertex.xyz);
o.uv0 = v.texcoord;
o.screenPos = ComputeScreenPos(o.pos);
return o;
}
half4 frag(v2f i) : SV_Target
{
// Normal Map의 Tiling & Offset 적용
float2 normalUV = i.uv0 * _GlassNormal_ST.xy + _GlassNormal_ST.zw;
// 1. Screen UV 계산. w로 나누어 Perspective Divide를 완료한다.
float2 screenUV = i.screenPos.xy / i.screenPos.w;
// 2. Scene Depth Buffer 샘플링
float sceneDepthRaw = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, screenUV).r;
// 3. Linear Eye Space Depth로 변환해 정확히 비교하기 쉽게 만든다.
float sceneLinearDepth = LinearEyeDepth(sceneDepthRaw, _ZBufferParams);
// 4. 현재 픽셀의 Linear Eye Space Depth. screenPos.w에서 직접 얻는다.
float currentLinearDepth = i.screenPos.w;
// 5. Depth 비교: 현재 픽셀이 Scene Object보다 앞에 있을 때만 마스크를 유지한다.
// 0.01의 기본 오프셋을 더해 Depth Fighting에 의한 깜빡임을 줄인다.
half depthMask = currentLinearDepth < sceneLinearDepth + 0.01 ? 1.0 : 0.0;
// Normal 샘플링 및 디코딩
half3 normalTS = UnpackNormal(SAMPLE_TEXTURE2D(_GlassNormal, sampler_GlassNormal, normalUV));
// Normal Strength 적용
normalTS.xy *= _NormalStrength * depthMask;
normalTS = normalize(normalTS);
// Tangent Space Normal을 [-1, 1]에서 [0, 1] 범위로 패킹한다.
half3 packedNormal = normalTS * 0.5 + 0.5;
// 왜곡 데이터를 반환한다. A Channel은 최종 마스크다.
return half4(packedNormal.rg, 0.0, depthMask);
}
ENDHLSL
}
패널에도 유리 Normal Texture 파라미터를 추가한다.
Properties
{
[Header(CubeMap)]
_CubeMap("CubeMap", Cube) = "white" {}
_CubeMapIntensity("CubeMap亮度", Range(0, 5)) = 1.0
_CubeMapScale("CubeMap缩放", Range(0, 2)) = 1
_GlassNormal("玻璃法线", 2D) = "bump" {}
_NormalStrength("法线强度", Range(0, 2)) = 1.0
[Header(Glass)]
_GlassTex("玻璃遮罩", 2D) = "white" {}
[HDR]_GlassColor("玻璃遮罩颜色", Color) = (1, 1, 1, 1)
_GlassIntensity("玻璃遮罩透明度", Range(0, 5)) = 1.0
_GlassPow("玻璃遮罩Pow", Range(0.1, 5)) = 1.0
}
주의할 점은, Shader에서 Depth Texture를 가져오려면 렌더링 설정에서 Depth Texture를 켜야 한다는 것이다.

URP 설정
FrameDebugger를 보면 우리가 추가한 RenderFeature를 확인할 수 있다.

FrameDebugger
A Channel에도 우리가 필요로 하는 마스크가 저장되어 있다.

FrameDebugger
후처리에서 스크린 왜곡 계산하기
이제 후처리에서 이 RT를 샘플링하고 UV를 오프셋하면 된다. 후처리 관련 부분은 예전에 쓴 아래 글을 참고하면 된다.
【Unity URP】URP 후처리 시스템 확장

후처리 코드를 수정하려면 URP Package를 Embedded Package로 바꿔야 한다. 먼저 프로젝트 경로에서 Library\PackageCache\com.unity.render-pipelines.universal@14.0.11을 찾는다.

LibraryPackageCache
com.unity.render-pipelines.universal@14.0.11 패키지를 프로젝트의 Packages 경로 아래로 복사한다. 그다음 manifest.json 파일을 열고, "com.unity.render-pipelines.universal": 뒤의 내용을 "file:Packages/com.unity.render-pipelines.universal@14.0.11"로 바꾼 뒤 Ctrl+S로 저장한다.

Packages
Unity로 돌아가 컴파일한 뒤, Package Manager를 연다.

Package Manager
URP Package 뒤에 “Custom”이 표시되면, 이 패키지가 Embedded Package로 바뀐 것이다.

FrameDebugger
Shader에 우리의 왜곡 RT를 선언한다.

UberPost.Shader
마지막으로 UV 계산에서 오프셋을 적용한다.

UberPost.Shader
// 스크린 왜곡
half4 warpNormal = SAMPLE_TEXTURE2D(_ScreenWarpTex, sampler_LinearClamp, input.texcoord);
half2 warpDir = warpNormal.rg * 2.0 - 1.0;
half2 warpOffset = warpDir * warpNormal.a;
input.texcoord += warpOffset;
OK, 이제 스크린 왜곡 효과도 완성되었다.
—————————————————————————————————————
팽팽수
팽팽수는 그냥 가장 단순한 Half Lambert만 사용했다.
Shader "Shader/PengPengShou"
{
Properties
{
_MainTex("主纹理", 2D) = "white" {}
}
SubShader
{
Tags
{
"LightMode" = "UniversalForward"
"RenderType" = "Opaque"
"Queue" = "Geometry"
}
LOD 100
Pass
{
Name "PengPengShou"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
// 입력 구조체
struct a2v
{
float4 vertex : POSITION;
float2 texcoord0 : TEXCOORD0;
float3 normal : NORMAL;
};
// 출력 구조체
struct v2f
{
float4 pos : SV_POSITION;
float2 uv0 : TEXCOORD0;
float3 normalWS : TEXCOORD1;
};
CBUFFER_START(UnityPerMaterial)
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
CBUFFER_END
// Vertex Shader
v2f vert(a2v v)
{
v2f o;
o.pos = TransformObjectToHClip(v.vertex.xyz);
o.uv0 = v.texcoord0;
o.normalWS = TransformObjectToWorldNormal(v.normal.xyz);
return o;
}
// Fragment Shader
half4 frag(v2f i) : SV_TARGET
{
float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv0);
// Half Lambert 조명 계산
float3 normalWS = normalize(i.normalWS);
Light mainLight = GetMainLight();
half NdotL = dot(normalWS, mainLight.direction);
half halfLambert = NdotL * 0.5 + 0.5;
col.rgb *= halfLambert;
return half4(col.rgb, 1.0);
}
ENDHLSL
}
}
}
구현한 효과는 아래와 같다.

팽팽수
—————————————————————————————————————
효과 최적화
후처리 파라미터를 조금 조정한다. 구체적인 파라미터는 아래와 같다.

후처리 파라미터

최종 효과
그다음 AI에게 Editor 파일도 하나 쓰게 했다.
using UnityEditor;
using UnityEngine;
//————————————————————————————————————————————————————————— 중국어 패널 ——————————————————————————————————————————————————————————————————————————————————
[CustomEditor(typeof(ScreenWarpRenderFeature))]
public class ScreenWarpRenderFeatureEditor : Editor
{
SerializedProperty _passSettings;
// 중국어 필드명 매핑
static readonly (string path, string label, string tooltip)[] Fields =
{
("lightModeTag", "LightMode标签", "Shader中LightMode标签名,用于筛选要绘制到扭曲RT的物体"),
("warpTextureName", "RT纹理名称", "扭曲RT在Shader中的全局纹理变量名,供后处理Shader采样"),
("renderPassEvent", "Pass插入时机", "此Pass在渲染管线中的执行位置"),
("layerMask", "渲染过滤层", "只绘制该层级的物体到扭曲RT"),
("resolutionScale", "RT分辨率缩放", "扭曲RT相对于屏幕分辨率的缩放比例,<1可节省带宽"),
("format", "RT格式", "扭曲RT的颜色格式,ARGBHalf提供足够精度"),
("filterMode", "RT过滤模式", "扭曲RT的纹理采样过滤方式"),
};
void OnEnable()
{
_passSettings = serializedObject.FindProperty("passSettings");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
// 제목
EditorGUILayout.LabelField("屏幕扭曲RenderFeature", EditorStyles.boldLabel);
EditorGUILayout.Space(4);
// 각 파라미터 그리기
foreach (var (path, label, tooltip) in Fields)
{
var prop = _passSettings.FindPropertyRelative(path);
if (prop == null)
{
EditorGUILayout.HelpBox($"属性 {path} 未找到,请检查字段名", MessageType.Warning);
continue;
}
var content = new GUIContent(label, tooltip);
EditorGUILayout.PropertyField(prop, content);
}
serializedObject.ApplyModifiedProperties();
}
}
패널 UI를 조금 보기 좋게 만들고, 중국어로 바꾸었다.

RenderFeature 파라미터 패널
그다음 스크린 왜곡 강도를 조금 조정한다. 마지막으로 최종 효과 영상을 다시 올려 둔다.

—————————————————————————————————————
후기
사실 아직 최적화할 공간은 있다. 지금은 스크린 왜곡 RenderFeature가 계속 계산된다. 현재 카메라 안에 왜곡이 필요한 물체가 있든 없든 상관없이 말이다.
RenderFeature에 스위치를 하나 추가하고, 장면에서 왜곡 효과가 필요한 모델에 Tag를 붙여 두면 된다. 만약 장면 안에 이 Tag를 가진 모델이 없다면 RenderFeature를 바로 끄고, 후처리 왜곡도 Keyword로 감싸 두었다가 필요할 때만 Keyword를 켜면 된다. 이런 최적화는 이번 프로젝트에서는 구현하지 않았다. 한번 해 보고 싶은 사람은 직접 시도해 보면 된다.
—————————————————————————————————————
프로젝트 GitHub 주소
https://github.com/PTSXDWD/KZSD_ZhanGui
GitHub - PTSXDWD/KZSD_ZhanGui: 原神空之神殿展示柜
原神空之神殿展示柜. Contribute to PTSXDWD/KZSD_ZhanGui development by creating an account on GitHub.
github.com
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] 싱글 Pass 체적 수체 Shader(계속 업데이트 중) (0) | 2026.06.16 |
|---|---|
| [번역] FastGeo Documentation 5.8 한국어 버전 (0) | 2026.06.14 |
| [번역] Unity로 Nanite 구현하기 (0) | 2026.06.11 |
| [번역] Unity에서 가상 텍스처 구현 (0) | 2026.06.10 |
| SDF Tool 1차 릴리스 했습니다. (0) | 2026.06.08 |