일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 게임 수학
- 가상 바이트
- Cell Look
- 벡터
- 작업 집합
- C언어
- Private Bytes
- 프로그래밍 기초
- Specular
- ColorGradingLutPass
- URP
- working set
- Cell Shader
- Rim Light
- Windows Build
- URP로 변경
- ASW(Application SpaceWarp)
- VR
- OculusMotionVectorPass
- Toon Shader
- Three(Two) Tone Shading
- 개인 바이트
- AppSW
- Virtual Byte
- 메모리 누수
- 3d
- Cartoon Rendering
- Today
- Total
WinCNT
포션의 의사 액체(Liquid) 셰이더 만들기! 본문
서론
아트 팀에서 포션이 움직이면 그 안의 액체가 흔들렸으면 좋겠다(…)는 리퀘스트가 있었다
하지만 그것만을 위해 유체 역학을 넣을 실력도 없고, 성능을 생각하면 넣어서도 안 된다!
다행히도 셰이더와 스크립트로 적당히 그럴듯한 Liquid 셰이더를 구현할 수 있을 것 같아서 이쪽으로 진행해봤다
레퍼런스는 아래의 유튜브 영상!
https://www.youtube.com/watch?v=DKSpgFuKeb4
다른 비슷한 영상들도 많았지만 이쪽이 제일 좋아서 많이 참고했다
특히 포션을 뒤집었을 때 액체가 위화감 없이 아래로 이동하는 부분이 특히 좋았다
우선은 샘플 코드부터!
이번에는 Shader와 그걸 보조하는 Script를 작성했다
액체의 흔들림을 구현하기 위해서는 Delta Time이나 직전 프레임의 Transform 정보가 필요한데 Shader만으로 어떻게 하기에는 복잡해지고 코스트도 늘어나기 때문…
아무튼 샘플 코드는 다음과 같다
Shader 코드!
Shader "SSS/Liquid"
{
Properties
{
[Header(Main)]
_LiquidTop("Liquid Top Offset", float) = 1
_LiquidBottom("Liquid Bottom Offset", float) = -1
_Fill("Fill", Range(0, 1)) = 1
[HDR]_TopColor("Top Color", Color) = (0, 0.75, 0.75, 1)
[HDR]_SideColor("Side Color", Color) = (0, 0.75, 0.25, 1)
[Header(Rim)]
_RimColor("Rim Color", Color) = (0.75, 1, 0.25, 1)
_RimSize("Rim Power", Range(0, 1)) = 0.75
_RimSmooth ("Smooth", Range(0, 3)) = 0.5
[Header(Foam)]
_FoamColor("Foam Color", Color) = (0.25, 1, 0.75, 1)
_FoamSmoothness("Foam Smoothness", float) = 0
_FoamWidth("Foam Width", float) = 0.05
[Header(From Scrpit)]
_WobbleX("Wobble X", Float) = 0
_WobbleZ("Wobble Z", Float) = 0
_BoundsCenter("Bounds Center", vector) = (0,0,0,0)
[HideInInspector] _QueueOffset("_QueueOffset", Float) = 0
[HideInInspector] _QueueControl("_QueueControl", Float) = -1
}
SubShader
{
Tags
{
"RenderPipeline"="UniversalPipeline"
"RenderType"="Opaque"
"DisableBatching"="True"
}
Pass
{
Name "Liquid"
Tags
{
"LightMode"="UniversalForward"
}
Cull Off
HLSLPROGRAM
// Pragmas
#pragma vertex vert
#pragma fragment frag
// Keywords
// Includes
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
// Main
float _LiquidTop;
float _LiquidBottom;
float _Fill;
half4 _TopColor;
half4 _SideColor;
// Rim
half4 _RimColor;
half _RimSize;
half _RimSmooth;
// Foam
half4 _FoamColor;
half _FoamSmoothness;
half _FoamWidth;
// From Script
half _WobbleX;
half _WobbleZ;
float4 _BoundsCenter;
CBUFFER_END
struct Attributes
{
float3 positionOS : POSITION;
float3 normalOS : NORMAL;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float3 positionWS : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float3 viewDirWS : TEXCOORD2;
float3 fillPosition: TEXCOORD3;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings vert(Attributes IN)
{
Varyings OUT;
UNITY_SETUP_INSTANCE_ID(IN);
UNITY_TRANSFER_INSTANCE_ID(IN, OUT);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
// Position
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.positionWS = TransformObjectToWorld(IN.positionOS.xyz);
// Normal and View Vector
OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS);
OUT.viewDirWS = normalize(GetWorldSpaceViewDir(OUT.positionWS));
// Fill Remap
_LiquidTop *= 0.01;
_LiquidBottom *= 0.01;
const float fillAmount = _LiquidBottom + _Fill * (_LiquidTop - _LiquidBottom);
// Fill Position Offset
const float3 boundsCenterWS = TransformObjectToWorld(_BoundsCenter.xyz);
OUT.fillPosition = OUT.positionWS - boundsCenterWS;
OUT.fillPosition.y -= fillAmount;
// Wobble Ratate
float3 rotatePos = OUT.fillPosition;
// Rotate Z Axis
OUT.fillPosition += float3(rotatePos.y, rotatePos.x, 0) * _WobbleX;
// Rotate X Axis
OUT.fillPosition += float3(rotatePos.x, rotatePos.z, -rotatePos.y) * _WobbleZ;
return OUT;
}
half4 frag(Varyings IN, bool cullFace : SV_IsFrontFace) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(IN);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(IN);
IN.normalWS = NormalizeNormalPerPixel(IN.normalWS);
IN.viewDirWS = SafeNormalize(IN.viewDirWS);
half4 outColor = half4(0,0,0,1);
// Fill Cutout
clip(step(IN.fillPosition.y, 0) - 0.5);
half4 outSideColor = _SideColor;
half4 outTopColor = _TopColor;
// Rim
float rim = 1.0 - saturate(dot(IN.normalWS, IN.viewDirWS));
float rimAmont = smoothstep(_RimSize, _RimSize + _RimSmooth, rim);
half3 rimColor = _RimColor.rgb * rimAmont;
// Foam Front
const float foamWidth = max(_FoamWidth, 0) * 0.01;
const float foamSmoothness = max(_FoamSmoothness, 0) * 0.01;
float frontFoamArea = smoothstep(0.5 - foamWidth - foamSmoothness, 0.5 - foamWidth, IN.fillPosition.y);
const half3 sideFoamColor = _FoamColor.rgb * frontFoamArea;
// Foam Back
float backFoamArea = step(0.5 - foamWidth, IN.fillPosition.y);
const half3 topFoamColor = _FoamColor.rgb * backFoamArea;
outSideColor.rgb += saturate(rimColor + sideFoamColor);
outTopColor.rgb += saturate(topFoamColor);
// Liquid Color
outColor.rgb = cullFace ? outSideColor.rgb : outTopColor.rgb;
return outColor;
}
ENDHLSL
}
}
}
Scrpit 코드!
using UnityEngine;
[ExecuteInEditMode]
public class Liquid : MonoBehaviour
{
private Mesh _mesh;
private Renderer _rend;
[SerializeField] private float maxWobble = 0.05f;
[SerializeField] private float wobbleSpeed = 1f;
[SerializeField] private float recovery = 1f;
private float _deltaTime = 0.0f;
private float _time = 0.5f;
// Liquid Shader
private Shader _liquidShader = null;
// Variables about Wobble
private float _wobbleAmountX;
private float _wobbleAmountZ;
private float _wobbleAmountToAddX;
private float _wobbleAmountToAddZ;
private float _pulse;
private Vector3 _velocity;
private Vector3 _pos;
private Vector3 _lastPos;
private Vector3 _lastRot;
private Vector3 _angularVelocity;
// Property ID
private int _wobbleX = -1;
private int _wobbleZ = -1;
private int _boundsCenter = -1;
void Awake()
{
Initialize();
}
void Update()
{
if (_rend.sharedMaterial.shader != _liquidShader) return;
_deltaTime = Time.deltaTime;
_time += _deltaTime;
if (_deltaTime != 0)
{
_wobbleAmountToAddX = Mathf.Lerp(_wobbleAmountToAddX, 0, (_deltaTime * recovery));
_wobbleAmountToAddZ = Mathf.Lerp(_wobbleAmountToAddZ, 0, (_deltaTime * recovery));
// Make a Sine Wave of the decreasing wobble
_pulse = 2 * Mathf.PI * wobbleSpeed;
_wobbleAmountX = _wobbleAmountToAddX * Mathf.Sin(_pulse * _time);
_wobbleAmountZ = _wobbleAmountToAddZ * Mathf.Sin(_pulse * _time);
// send data to Liquid Shader
_rend.sharedMaterial.SetFloat(_wobbleX, _wobbleAmountX);
_rend.sharedMaterial.SetFloat(_wobbleZ, _wobbleAmountZ);
// Velocity
var tf = transform;
var transformPosition = tf.position;
var transformRotAngles = tf.rotation.eulerAngles;
_velocity = (_lastPos - transformPosition) / Time.deltaTime;
_angularVelocity = transformRotAngles - _lastRot;
// Add clamped velocity to wobble
_wobbleAmountToAddX += Mathf.Clamp((_velocity.x + (_angularVelocity.z * 0.2f)) * maxWobble, -maxWobble, maxWobble);
_wobbleAmountToAddZ += Mathf.Clamp((_velocity.z + (_angularVelocity.x * 0.2f)) * maxWobble, -maxWobble, maxWobble);
// Keep last position
_lastPos = transformPosition;
_lastRot = transformRotAngles;
// Initialize sing input
if (_pulse * _time > Mathf.PI * 2.0f)
{
_time = 0.0f;
}
}
UpdatePos(_deltaTime);
}
private void Initialize()
{
GetMeshAndRend();
SetMaterial();
GetPropertyID();
}
private void GetMeshAndRend()
{
if (_mesh == null)
_mesh = GetComponent<MeshFilter>().sharedMesh;
if (_rend == null)
_rend = GetComponent<Renderer>();
}
private void SetMaterial()
{
_liquidShader = Shader.Find("SSS/Liquid");
}
private void GetPropertyID()
{
_wobbleX = Shader.PropertyToID("_WobbleX");
_wobbleZ = Shader.PropertyToID("_WobbleZ");
_boundsCenter = Shader.PropertyToID("_BoundsCenter");
}
private void UpdatePos(float deltaTime)
{
_rend.sharedMaterial.SetVector(_boundsCenter, new Vector3(_mesh.bounds.center.x, _mesh.bounds.center.y, _mesh.bounds.center.z));
}
}
Shader부터 간략 설명
먼저 버텍스 셰이더 간략하게 정리해보자
이건 포션이 채워진 정도를 0~1로 컨트롤하게 하기 위한 코드이다
// Fill Remap
_LiquidTop *= 0.01;
_LiquidBottom *= 0.01;
const float fillAmount = _LiquidBottom + _Fill * (_LiquidTop - _LiquidBottom);
포션의 액체 모델링에 따라서 버텍스의 포지션은 각각 다른 값을 갖고, 그걸로도 포션 액체의 채워진 정도를 조절할 수 있다
하지만 그러면 사용하는 입장에서는 너무 불편하니…
그래서 우선은 0~1로 채워진 양을 컨트롤하는 _Fill이란 프로퍼티를 준비했다
그리고 포션의 액체 모델링(Prefab)에 따라 _Fill가 0일 때 _LiquidBottom를 조정해서 액체가 없도록 조정하고, 1 일 때는 _LiquidTop로 꽉 차도록 조정했다
이러면 사용자는 (처음 설정은 귀찮지만) _Fill만으로 포션의 채워진 정도를 컨트롤할 수 있게 된다!
물론 셰이더 입장에서는 그런 건(…) 모르기 때문에 _Fill의 값에 따라서 다시 제대로 된 포지션 값을 구해야 하는데 그게 위의 코드이다
다음은 스크립트에서 보내준 메쉬의 Bounding Volume의 Center 값과 위에서 구한 fillAmount를 이용해서 실제로 채워진 정도를 조정하는 처리이다
// Fill Position Offset
const float3 boundsCenterWS = TransformObjectToWorld(_BoundsCenter.xyz);
OUT.fillPosition = OUT.positionWS - boundsCenterWS;
OUT.fillPosition.y -= fillAmount;
샘플 스크립트에서 넘겨주는 메쉬의 Bounding Volume의 Center 값은 로컬 스페이스이기 때문에, 이를 월드 스페이스로 바꿔주고 positionWS에서 빼줘야 한다
만약 이 처리가 없다면 액체를 뒤집었을 때 다음과 문제점이 생긴다…
아무튼 fillAmount까지 빼면 fillPosition.y의 값은 남길 부분은 0보다 작고, 폐기할 부분은 0보다 큰 상태가 된다
이걸 프래그먼트 셰이더에서 적당히 step과 Clip을 하면 액체의 채워진 정도가 구현된다!
(자세한 건 프래그먼트 셰이더에서 설명)
다음 부분은 액체의 흔들림을 구현하기 위한 코드이다
// Wobble Ratate
float3 rotatePos = OUT.fillPosition;
// Rotate Z Axis
OUT.fillPosition += float3(rotatePos.y, rotatePos.x, 0) * _WobbleX;
// Rotate X Axis
OUT.fillPosition += float3(rotatePos.x, rotatePos.z, -rotatePos.y) * _WobbleZ;
_WobbleX이나 _WobbleZ는 스크립트에서 계산하며, 셰이더에는 그 값에 따라 각각 Z축, X축으로 회전시키는 역할만 한다
원래는 유니티 셰이더 그래프의 회전 행렬을 계산하는 노드를 참고로 함수로 만들었으나, 이번엔 고정된 축으로만 회전하기 때문에 필요한 부분만 남겼다
다음은 프래그먼트 셰이더이다
// Fill Cutout
clip(step(IN.fillPosition.y, 0) - 0.5);
버텍스 셰이더에서 계산한 포션의 액체가 채워진 정도의 값으로 안 채워진 부분을 폐기한다
참고로 fillPosition.y은 남길 부분은 0보다 작고, 폐기할 부분은 0보다 큰 상태로 넘어온다
알기 쉽게 좀 더 시각적으로 표현해보면 다음과 같다
outColor.rgb = IN.fillPosition.y * 10;
return outColor;
outColor.rgb = step(IN.fillPosition.y, 0);
return outColor;
step을 통해 0보다 작으면 1, 크면 0으로 출력하고, 적절히 clip으로 폐기하면 포션의 채워진 정도를 구현 완료!!
다음은 포션의 색 처리이다
액체에 Rim을 추가하거나 윗 부분의 일부를 거품(Foam)마냥 색을 바꾸거나 한다
half4 outSideColor = _SideColor;
half4 outTopColor = _TopColor;
// Rim
float rim = 1.0 - saturate(dot(IN.normalWS, IN.viewDirWS));
float rimAmont = smoothstep(_RimSize, _RimSize + _RimSmooth, rim);
half3 rimColor = _RimColor.rgb * rimAmont;
// Foam Front
const float foamWidth = max(_FoamWidth, 0) * 0.01;
const float foamSmoothness = max(_FoamSmoothness, 0) * 0.01;
float frontFoamArea = smoothstep(0.5 - foamWidth - foamSmoothness, 0.5 - foamWidth, IN.fillPosition.y);
const half3 sideFoamColor = _FoamColor.rgb * frontFoamArea;
// Foam Back
float backFoamArea = step(0.5 - foamWidth, IN.fillPosition.y);
const half3 topFoamColor = _FoamColor.rgb * backFoamArea;
outSideColor.rgb += saturate(rimColor + sideFoamColor);
outTopColor.rgb += saturate(topFoamColor);
// Liquid Color
outColor.rgb = cullFace ? outSideColor.rgb : outTopColor.rgb;
return outColor;
하지만 여기서 놀라운 사실이 하나!
포션의 윗 부분은 사실 액체 오브젝트의 Back Face였던 것이다!!
아니 현실에서 병에 담긴 액체를 살펴보면 그렇긴 한데, 그렇게 생각한 적은 없으니 처음에는 그 발상에 좀 놀랐다
아무튼 포션의 겉 부분과 윗 부분을 나눠서 색을 설정하기 위해서는 현재 픽셀이 Face의 노멀이 정방향인지 역방향인지 알아야하는데…
이는 딱히 계산할 필요 없이 HLSLSemantics의 SV_IsFrontFace를 이용하면 간단한게 알 수 있다
다음과 같이 프래그먼트 셰이더의 인수로 설정하고 사용하면 된다
half4 frag(Varyings IN, bool cullFace : SV_IsFrontFace) : SV_Target
{
// ...생략...
outColor.rgb = cullFace ? outSideColor.rgb : outTopColor.rgb;
// ...생략...
}
참고로 VFACE라는 것도 있었는데…
공식 문서에 따르면 Direct3D 9 Shader Model 3.0에서 사용할 수 있었다고 한다
Direct3D 10 이상이면 얌전하게 SV_IsFrontFace를 사용하자
근데 Direct3D 10도 있었는데 없었지 않았나?
다음은 스크립트!
스크립트에서는 흔들림 값이나 메쉬의 Bounding Volume을 셰이더에 보내주는 처리를 한다
void Update()
{
if (_rend.sharedMaterial.shader != _liquidShader) return;
_deltaTime = Time.deltaTime;
_time += _deltaTime;
if (_deltaTime != 0)
{
_wobbleAmountToAddX = Mathf.Lerp(_wobbleAmountToAddX, 0, (_deltaTime * recovery));
_wobbleAmountToAddZ = Mathf.Lerp(_wobbleAmountToAddZ, 0, (_deltaTime * recovery));
// Make a Sine Wave of the decreasing wobble
_pulse = 2 * Mathf.PI * wobbleSpeed;
_wobbleAmountX = _wobbleAmountToAddX * Mathf.Sin(_pulse * _time);
_wobbleAmountZ = _wobbleAmountToAddZ * Mathf.Sin(_pulse * _time);
// send data to Liquid Shader
_rend.sharedMaterial.SetFloat(_wobbleX, _wobbleAmountX);
_rend.sharedMaterial.SetFloat(_wobbleZ, _wobbleAmountZ);
// Velocity
var tf = transform;
var transformPosition = tf.position;
var transformRotAngles = tf.rotation.eulerAngles;
_velocity = (_lastPos - transformPosition) / Time.deltaTime;
_angularVelocity = transformRotAngles - _lastRot;
// Add clamped velocity to wobble
_wobbleAmountToAddX += Mathf.Clamp((_velocity.x + (_angularVelocity.z * 0.2f)) * maxWobble, -maxWobble, maxWobble);
_wobbleAmountToAddZ += Mathf.Clamp((_velocity.z + (_angularVelocity.x * 0.2f)) * maxWobble, -maxWobble, maxWobble);
// Keep last position
_lastPos = transformPosition;
_lastRot = transformRotAngles;
// Initialize sign input
if (_pulse * _time > Mathf.PI * 2.0f)
{
_time = 0.0f;
}
}
UpdatePos(_deltaTime);
}
흔들림은 사인 함수를 베이스로 구현하고 있다
직전 트랜스폼 정보와 Delta Time 등으로 흔들림과 회복 정도를 반영한다
이건 메쉬의 Bounding Volume을 셰이더에 보내주는 코드이다
private void UpdatePos(float deltaTime)
{
_rend.sharedMaterial.SetVector(_boundsCenter, new Vector3(_mesh.bounds.center.x, _mesh.bounds.center.y, _mesh.bounds.center.z));
}
Mesh.bounds는 로컬 스페이스의 값이라서 셰이더 쪽에서 월드 스페이스로 트랜스폼해서 사용하고 있다
그런데 Renderer.bounds는 월드 스페이스의 값이라는데…
Renderer.bounds를 바로 보내면 셰이더에서 트랜스폼 처리를 생략할 수 있지 않을까라고 이제야 깨달았다(실험은 안 해봤지만)
결과
다음은 에디터에서 움직여 본 결과이다
움직임에 따라 액체가 흔들리는 것을 알 수 있다
뭔가 에디터에서만 보는 게 심심해서 VR 기기에서도 움직여봤다
액체가 좀 더 역동적으로 움직이니 의외로 이것만으로 꽤나 재밌는 체험이 되었다!
마무리
실제 유체 역학에 의거한 셰이더는 아니었지만 생각했던 것 이상으로 액체스러운 셰이더를 구현할 수 있어서 만족했던 태스크였다
참고 사이트
https://www.youtube.com/watch?v=DKSpgFuKeb4
'Unity > URP or Shader 관련' 카테고리의 다른 글
높이 안개(Height Fog)를 Global Shader Variable로 변경하기! (0) | 2024.09.18 |
---|---|
커스텀 셰이더에 높이 안개(Height Fog) 구현해보기! (0) | 2024.08.01 |
Visual Effect Graph(VFX Graph)의 도입 체험기! (0) | 2024.07.09 |
Alpha 값으로 Blue Noise를 이용한 Dither를 해보자! (0) | 2024.06.24 |
Fill Amount 기능이 있는 간단(?) Dial Gauge UI(가칭) 셰이더 만들어보기! (1) | 2024.06.16 |