티스토리 뷰
했다.
https://github.com/keijiro/OpticalFlowTest
GitHub - keijiro/OpticalFlowTest: Unity sample project with Lucas-Kanade optical flow estimation
Unity sample project with Lucas-Kanade optical flow estimation - keijiro/OpticalFlowTest
github.com
이 글에 이어서 , 프로젝트 구현중.
https://ing-min.tistory.com/300
Unity _ image sequence 조형물 생성 및 플레이
1. FinalAtlasTool로 조형물과 데이터(인덱스, UV)를 한 번에 생성하고, WorldEngine이 런타임에 이 모든 것을 관장하는 구조창조자 (FinalAtlasTool): 이미지들을 아틀라스 텍스처 하나로 합치고, 각 이미지를
ing-min.tistory.com
작동만 간단히
1. 프로젝트 목표 🎯
이 프로젝트의 최종 목표는, 연속된 영상의 두 프레임(n번째와 n+1번째)을 비교하여 이미지 안의 움직임을 분석하는 'Optical Flow(광학 흐름)' 기술을 시각화하는 것입니다.
우리는 Plane A에 n번째 프레임 이미지를, Plane B에 n+1번째 프레임 이미지를 배치합니다. 그리고 Plane A의 각 지점이 Plane B의 어느 지점으로 이동했는지를 계산하여, 그 경로를 수많은 **'연결선'**으로 그려낼 것입니다.
2. 필요한 핵심 에셋 3가지 🧩
이 프로젝트는 크게 3개의 핵심 요소로 구성됩니다.
- OpticalFlowEstimator.cs: 두 이미지의 차이를 분석하여 '움직임 지도'인 FlowMap을 계산하는 공장장 역할을 합니다.
- PlaneOpticalFlowTest.cs: Unity 씬의 오브젝트들(Plane, VFX 등)을 관리하고, OpticalFlowEstimator에게 일하라고 명령을 내리는 현장 감독관 역할을 합니다.
- VFX Graph 에셋: C# 스크립트로부터 받은 데이터를 바탕으로, 수만 개의 파티클(선)을 화면에 실제로 그려내는 아티스트 역할을 합니다.
3. Unity 씬(Scene) 설정 (Step-by-Step) 🏗️
가장 먼저, 프로젝트를 구성할 모든 요소를 씬에 배치하고 서로 연결해야 합니다.
- Plane 오브젝트 2개 생성:
- Hierarchy 창에서 우클릭 → 3D Object → Plane을 선택하여 두 개를 만듭니다.
- 각각의 이름을 Plane A와 Plane B로 변경합니다.
- 준비한 두 개의 순차적인 이미지(n번째, n+1번째)를 각각 Plane A와 Plane B에 드래그 앤 드롭하여 머티리얼을 입힙니다.
- VFX Graph 에셋 및 오브젝트 생성:
- Project 창에서 우클릭 → Create → Visual Effects → Visual Effect Graph를 선택하여 에셋을 만듭니다. 이름은 OpticalFlowVFX로 지정합니다.
- Hierarchy 창에 빈 GameObject를 하나 만들고 이름을 VFX로 지정한 뒤, 방금 만든 OpticalFlowVFX 에셋을 드래그 앤 드롭합니다.
- Optical Flow 매니저 생성:
- Hierarchy 창에 빈 GameObject를 하나 만들고 이름을 OpticalFlowManager로 지정합니다.
- 아래 4단계에서 제공하는 OpticalFlowEstimator.cs 스크립트와, 프로젝트에 포함된 Flow.compute 셰이더를 이 오브젝트에 추가합니다.
- 최종 제어기(Controller) 설정:
- Hierarchy 창에 빈 GameObject를 하나 더 만들고 이름을 MainController로 지정합니다.
- 아래 4단계에서 제공하는 PlaneOpticalFlowTest.cs 스크립트를 이 MainController 오브젝트에 추가합니다.
- MainController의 Inspector 창을 보면 여러 개의 빈 슬롯이 보입니다. 아래와 같이 각 슬롯에 맞는 오브젝트를 드래그 앤 드롭하여 연결해 주세요.
- Plane A: Hierarchy의 Plane A 오브젝트
- Plane B: Hierarchy의 Plane B 오브젝트
- Optical Flow: Hierarchy의 OpticalFlowManager 오브젝트
- Visual Effect: Hierarchy의 VFX 오브젝트
4. 최종 C# 스크립트 📜
아래는 우리가 최종적으로 완성한 두 개의 C# 스크립트입니다. 이 코드를 복사하여 각각의 파일에 붙여넣으세요.
OpticalFlowEstimator.cs (FlowMap 계산 공장장)
이 스크립트는 FlowMap의 해상도를 Inspector에서 직접 설정하고, 두 이미지를 입력받아 FlowMap을 계산하는 핵심적인 역할을 합니다.
using UnityEngine;
public class OpticalFlowEstimator : MonoBehaviour
{
#region Editable attributes
// 이 해상도가 FlowMap의 크기를 결정하는 유일한 기준입니다.
[SerializeField] Vector2Int _resolution = new Vector2Int(256, 256);
[SerializeField, Range(0, 5)] int _downsample = 2;
[SerializeField] ComputeShader _compute = null;
#endregion
#region Public properties
// VFX Graph 및 다른 스크립트에서 최종 FlowMap 텍스처를 참조하기 위한 속성
public Texture AsRenderTexture => _flow;
// 외부에서 최종 계산된 해상도를 읽기 위한 속성
public int ResolutionWidth { get; private set; }
public int ResolutionHeight { get; private set; }
// Compute Shader가 할당되었는지 확인하기 위한 속성
public bool HasComputeShader => _compute != null;
#endregion
#region Private members
RenderTexture _flow; // 최종 Flow 벡터 데이터 (RG 채널)
RenderTexture _prev; // 이전 프레임의 흑백 이미지
RenderTexture _temp; // 현재 프레임의 흑백 이미지
#endregion
#region MonoBehaviour implementation
void Start()
{
// 해상도 및 다운샘플링을 적용하여 실제 처리될 텍스처 크기 계산
ResolutionWidth = _resolution.x >> _downsample;
ResolutionHeight = _resolution.y >> _downsample;
// 필요한 렌더 텍스처들을 고정된 크기로 단 한 번만 생성합니다.
_flow = new RenderTexture(ResolutionWidth, ResolutionHeight, 0, RenderTextureFormat.RGFloat);
_prev = new RenderTexture(ResolutionWidth, ResolutionHeight, 0, RenderTextureFormat.R8);
_temp = new RenderTexture(ResolutionWidth, ResolutionHeight, 0, RenderTextureFormat.R8);
// Compute Shader가 텍스처에 쓰기 작업을 할 수 있도록 설정
_flow.enableRandomWrite = true;
_prev.enableRandomWrite = true;
_temp.enableRandomWrite = true;
_flow.Create();
_prev.Create();
_temp.Create();
Debug.Log($"OpticalFlowEstimator 리소스가 고정 크기로 생성되었습니다: {ResolutionWidth}x{ResolutionHeight}");
}
void OnDestroy()
{
// 씬이 종료될 때 메모리에서 텍스처를 해제
Destroy(_flow);
Destroy(_prev);
Destroy(_temp);
}
#endregion
#region Public methods
// Optical Flow를 계산하는 핵심 함수입니다.
public void ProcessFlow(Texture sourceAtlas, Rect sourceUV, Texture destAtlas, Rect destUV)
{
if (_compute == null)
{
Debug.LogError("ComputeShader가 할당되지 않았습니다!");
return;
}
// 입력받은 두 텍스처를 내부 처리용 텍스처로 복사(Blit)합니다.
Graphics.Blit(sourceAtlas, _prev, sourceUV.size, sourceUV.position);
Graphics.Blit(destAtlas, _temp, destUV.size, destUV.position);
// Compute Shader에 텍스처들을 전달합니다.
_compute.SetTexture(0, "Current", _temp);
_compute.SetTexture(0, "Previous", _prev);
_compute.SetTexture(0, "OutputFlow", _flow);
// Compute Shader를 실행합니다.
_compute.Dispatch(0, _flow.width / 8, _flow.height / 8, 1);
}
// 테스트 목적으로 사용되는 함수입니다.
public void CreateTestFlowMap()
{
// FlowMap을 단순하고 일정한 벡터 값으로 채웁니다.
Graphics.SetRenderTarget(_flow);
// R=0.75 (오른쪽), G=0.5 (Y축 중앙), B=0 (미사용)
GL.Clear(true, true, new Color(0.75f, 0.5f, 0f, 1f));
Graphics.SetRenderTarget(null);
}
#endregion
}
PlaneOpticalFlowTest.cs (현장 감독관)
이 스크립트는 씬의 모든 요소를 연결하고, FlowMap 계산을 요청한 뒤, 최종 데이터를 VFX Graph로 보내는 역할을 합니다.
using UnityEngine;
using UnityEngine.VFX;
using System.Collections;
public class PlaneOpticalFlowTest : MonoBehaviour
{
[Header("Plane 오브젝트들")]
public Transform planeA;
public Transform planeB;
[Header("Optical Flow")]
public OpticalFlowEstimator opticalFlow;
[Header("VFX 연결")]
public VisualEffect visualEffect;
[Header("테스트 모드")]
public bool useTestFlowMap = true;
public bool autoUpdate = false;
public float updateInterval = 0.5f;
// VFX 이벤트 및 프로퍼티 ID 캐싱
private static readonly int OnStartEventID = Shader.PropertyToID("OnStart");
private static readonly int FlowMapID = Shader.PropertyToID("FlowMap");
private static readonly int GridWidthID = Shader.PropertyToID("GridWidth");
private static readonly int GridHeightID = Shader.PropertyToID("GridHeight");
private static readonly int PlaneA_PositionID = Shader.PropertyToID("PlaneA_Position");
private static readonly int PlaneB_PositionID = Shader.PropertyToID("PlaneB_Position");
private static readonly int PlaneA_ScaleID = Shader.PropertyToID("PlaneA_Scale");
private static readonly int PlaneB_ScaleID = Shader.PropertyToID("PlaneB_Scale");
private Renderer planeARenderer;
private Renderer planeBRenderer;
private Texture planeATexture;
private Texture planeBTexture;
void Start()
{
if (opticalFlow == null)
{
opticalFlow = FindObjectOfType<OpticalFlowEstimator>();
if (opticalFlow == null)
{
Debug.LogError("OpticalFlowEstimator를 찾을 수 없습니다!");
enabled = false;
return;
}
}
if (planeA != null && planeA.GetComponent<Renderer>() != null)
planeATexture = planeA.GetComponent<Renderer>().material.mainTexture;
if (planeB != null && planeB.GetComponent<Renderer>() != null)
planeBTexture = planeB.GetComponent<Renderer>().material.mainTexture;
StartCoroutine(InitializeFlow());
if (autoUpdate)
StartCoroutine(AutoUpdateFlow());
}
IEnumerator InitializeFlow()
{
yield return new WaitForSeconds(0.1f);
GenerateFlowMapAndSetupVFX();
}
void GenerateFlowMapAndSetupVFX()
{
if (opticalFlow == null || visualEffect == null) return;
if (useTestFlowMap)
{
opticalFlow.CreateTestFlowMap();
}
else if (planeATexture != null && planeBTexture != null)
{
opticalFlow.ProcessFlow(planeATexture, new Rect(0, 0, 1, 1), planeBTexture, new Rect(0, 0, 1, 1));
}
else
{
opticalFlow.CreateTestFlowMap();
}
SetupVFX();
}
void SetupVFX()
{
if (visualEffect == null || opticalFlow == null) return;
// VFX Graph에 최종적으로 필요한 데이터만 전달
visualEffect.SetTexture(FlowMapID, opticalFlow.AsRenderTexture);
visualEffect.SetInt(GridWidthID, opticalFlow.ResolutionWidth);
visualEffect.SetInt(GridHeightID, opticalFlow.ResolutionHeight);
if (planeA != null)
{
visualEffect.SetVector3(PlaneA_PositionID, planeA.position);
visualEffect.SetVector3(PlaneA_ScaleID, planeA.localScale);
}
if (planeB != null)
{
visualEffect.SetVector3(PlaneB_PositionID, planeB.position);
visualEffect.SetVector3(PlaneB_ScaleID, planeB.localScale);
}
// 이벤트 전송
visualEffect.SendEvent(OnStartEventID);
}
IEnumerator AutoUpdateFlow()
{
while (autoUpdate)
{
yield return new WaitForSeconds(updateInterval);
GenerateFlowMapAndSetupVFX();
}
}
[ContextMenu("Trigger VFX Event")]
void TriggerVFXEvent()
{
if (visualEffect != null)
{
visualEffect.SendEvent(OnStartEventID);
}
}
}
5. 최종 VFX Graph 제작 (Step-by-Step) 🎨
이제 이 프로젝트의 심장인 VFX Graph를 만듭니다. OpticalFlowVFX 에셋을 더블클릭하여 에디터를 열고, 아래 순서대로 노드를 구성하세요.
A. Blackboard (데이터 목록 준비)
가장 먼저, C# 스크립트와 데이터를 주고받을 '보관함'을 만듭니다. Blackboard 창에서 + 버튼을 눌러 아래 목록과 똑같이 프로퍼티를 추가해 주세요.
- Exposed Properties (C#에서 제어할 데이터):
- Texture2D: FlowMap
- int: GridWidth
- int: GridHeight
- Vector3: PlaneA_Position
- Vector3: PlaneA_Scale
- Vector3: PlaneB_Position
- Vector3: PlaneB_Scale
- Custom Attributes (VFX 내부에서만 사용할 변수):
- Vector3: StartPosition (파티클의 원본 시작 위치를 저장할 변수)
B. Spawn (파티클 생성 신호)
파티클이 언제 생성될지를 결정합니다.
- Spawn 컨텍스트를 클릭하고, OnEvent 드롭다운 메뉴에서 OnStart 를 선택합니다.
- Single Burst 블록을 추가하고, Count 입력에 GridWidth 와 GridHeight 를 Multiply 노드로 곱한 값을 연결합니다.
C. Initialize (모든 마법이 일어나는 곳)
파티클이 생성되는 순간의 모든 속성을 설정합니다. 이 부분이 선의 '시작점'과 '끝점'을 정의하는 핵심 로직입니다.
- 파티클 수명 설정:
- Set Lifetime 블록을 추가하고, 값을 3.0 정도로 설정합니다. (이 값이 0이면 오류 발생!)
- 첫 번째 Set Position 블록 (시작점 position 설정):
- Set Position (Sequential 3D) 블록을 추가합니다.
- 이 블록의 Origin, Axis X, Axis Y 입력값을 PlaneA_Position 과 PlaneA_Scale 데이터를 사용하여 정확히 설정합니다.
- Write Position: 체크
- Write Target Position: 체크 해제
- 시작 위치 저장 (아주 중요!):
- Set StartPosition 블록을 추가합니다.
- Get Attribute: position 노드를 만들어 Set StartPosition 블록에 연결합니다. 이로써 Update에서 덮어쓰기 되더라도 원본 시작 위치가 StartPosition에 안전하게 보존됩니다.
- TargetIndex 계산 (목표 지점의 순번 계산):
- Get Attribute: particleId를 사용하여 LocalUV (0~1 범위 Vector2) 를 계산합니다.
- LocalUV와 FlowMap을 Sample Texture2D 노드로 샘플링하여 FlowOffset을 구합니다.
- LocalUV에 FlowOffset을 Add 노드로 더해 EndUV를 만듭니다.
- EndUV를 1차원 인덱스로 변환하여 TargetIndex 를 계산합니다. (Y * GridWidth + X)
- 두 번째 Set Position 블록 (끝점 targetPosition 설정):
- Set Position (Sequential 3D) 블록을 하나 더 추가합니다.
- Index 입력에 위에서 계산한 TargetIndex 를 연결합니다.
- Origin, Axis X, Axis Y는 첫 번째 블록과 완벽히 동일한 로직으로 계산하되, PlaneA의 데이터 대신 모두 PlaneB의 데이터(PlaneB_Position, PlaneB_Scale)를 사용합니다.
- Write Position: 체크 해제
- Write Target Position: 체크
D. Update (선 그리기)
매 프레임마다 파티클의 위치를 업데이트하여 '선'처럼 보이게 만듭니다.
- Set Position 블록을 Update 컨텍스트에 추가합니다.
- Lerp (Vector3) 노드를 만듭니다.
- Lerp 노드의 입력을 아래와 같이 연결합니다:
- A (출발점): Get StartPosition (Initialize에서 저장한 원본 위치)
- B (도착점): Get Attribute: targetPosition
- T (진행률): particle.age를 particle.lifetime으로 나눈 값
- Lerp 노드의 최종 출력값을 Set Position 블록에 연결합니다.
6. 실행 및 확인 🎉
모든 설정이 끝났습니다. 이제 Unity 에디터에서 Play 버튼을 누르세요. Plane A의 각 지점에서 시작하여, FlowMap이 알려주는 Plane B의 대응점을 향해 아름다운 선들이 그려지는 것을 확인하실 수 있을 겁니다!
실패의 기록
단순히 옵티컬 플로우 효과를 모방하는 것이 아니라, AI가 생성한 두 개의 개별적인 시공간(프레임 N과 프레임 N+1) 사이에서, 빛의 입자들이 자신의 정체성을 유지하며 다음 위치로 이동하는 **'정확한 경로'**를 3D 공간에 그려내는 것입니다.
## 3대 핵심 원칙
이 목표를 달성하기 위해, 우리의 시스템은 다음 세 가지 원칙을 반드시 지켜야 합니다.
- 1:1 픽셀 대응 (1-to-1 Pixel Correspondence) 시작 프레임(N)의 특정 좌표 (u,v)에 있던 파티클은, 옵티컬 플로우 FlowMap이 알려주는 변위 값에 따라, 도착 프레임(N+1)의 **정확히 대응되는 좌표 (u+dx, v+dy)**로 이동해야 합니다. 이것은 단순한 방향이 아닌, 정해진 목적지를 의미합니다.
- 3D 공간상의 변환 (Transformation in 3D Space) 이 1:1 대응은 2D 텍스처 공간에서만 일어나는 것이 아닙니다. 3D 공간에 배치된 시작 조각(ShardTransform_Start)의 표면에서, 3D 공간에 배치된 도착 조각(ShardTransform_End)의 표면까지의 입체적인 이동으로 구현되어야 합니다.
- 동적 및 연속성 (Dynamic and Continuous) 이 '빛의 여정'은 게임 시작 시 한 번만 일어나는 것이 아닙니다. 플레이어가 레일을 따라 다음 프레임으로 이동할 때마다, 시스템은 새로운 시작점과 도착점을 실시간으로 감지하고, 그에 맞는 새로운 빛의 경로를 계속해서 생성해야 합니다.
- WorldEngine: 플레이어 위치 기반으로 현재/다음 프레임의 아틀라스 텍스처와 UV 영역을 찾음
- OpticalFlowEstimator: 두 이미지 영역 사이의 Lucas-Kanade 옵티컬 플로우를 계산해서 FlowMap 생성
- OpticalFlowManager: 모든 데이터를 VFX Graph로 전달하는 관제탑
- VFX Graph: FlowMap을 기반으로 수천 개의 파티클이 정확한 픽셀 대응 경로로 3D 공간을 이동
using UnityEngine;
using UnityEngine.VFX;
using System.Collections;
public class OpticalFlowManager : MonoBehaviour
{
[Header("핵심 연결 요소")]
public WorldEngine worldEngine;
public OpticalFlowEstimator opticalFlow; // 다시 필요합니다.
public VisualEffect visualEffect;
private int _lastIndex = -1;
// 사용하는 모든 파라미터 ID
private static readonly int FlowMapID = Shader.PropertyToID("FlowMap"); // 다시 추가
private static readonly int GridWidthID = Shader.PropertyToID("GridWidth");
private static readonly int GridHeightID = Shader.PropertyToID("GridHeight");
private static readonly int ShardTransformStartID = Shader.PropertyToID("ShardTransform_Start");
private static readonly int ShardTransformEndID = Shader.PropertyToID("ShardTransform_End");
private static readonly int ReinitEventID = Shader.PropertyToID("OnReinit");
//private bool _isInitialized = false;
void Update()
{
if (worldEngine == null || visualEffect == null || opticalFlow == null) return;
int currentIndex = worldEngine.GetCurrentShardIndex();
if (currentIndex != -1 && currentIndex != _lastIndex)
{
_lastIndex = currentIndex;
bool success = worldEngine.GetFlowInputTextures(currentIndex, out var sAtlas, out var sUV, out var sT, out var dAtlas, out var dUV, out var dT);
if (success)
{
StartCoroutine(ProcessNewConnection(sAtlas, sUV, sT, dAtlas, dUV, dT));
}
}
}
private IEnumerator ProcessNewConnection(Texture sAtlas, Rect sUV, Transform sT, Texture dAtlas, Rect dUV, Transform dT)
{
// 1. 하드 리셋
visualEffect.enabled = false;
yield return null;
visualEffect.enabled = true;
// 2. Optical Flow 및 모든 데이터 설정
opticalFlow.ProcessFlow(sAtlas, sUV, dAtlas, dUV);
visualEffect.SetTexture(FlowMapID, opticalFlow.AsRenderTexture);
visualEffect.SetInt(GridWidthID, opticalFlow.ResolutionWidth);
visualEffect.SetInt(GridHeightID, opticalFlow.ResolutionHeight);
visualEffect.SetMatrix4x4(ShardTransformStartID, sT.localToWorldMatrix);
visualEffect.SetMatrix4x4(ShardTransformEndID, dT.localToWorldMatrix);
// 3. 이벤트 전송
visualEffect.SendEvent(ReinitEventID);
Debug.Log($"연결선 생성! 시작: {sT.name}, 도착: {dT.name}");
}
}
using UnityEngine;
using System.Linq;
public class WorldEngine : MonoBehaviour
{
[Header("Core Components")]
public Transform player;
public Transform[] mapRoots;
[Header("Optimization Settings")]
public float searchRadius = 5f;
public LayerMask shardLayer;
private SculptureAtlasData[] sculptureData;
void Awake()
{
sculptureData = mapRoots.Select(root => root.GetComponent<SculptureAtlasData>()).ToArray();
}
public int GetCurrentShardIndex()
{
if (player == null) return -1;
Collider[] nearbyColliders = Physics.OverlapSphere(player.position, searchRadius, shardLayer);
if (nearbyColliders.Length == 0) return -1;
float minDistance = float.MaxValue;
int closestIndex = -1;
foreach (var col in nearbyColliders)
{
ShardData shardData = col.GetComponent<ShardData>();
if (shardData == null) continue;
float distance = Vector3.Distance(player.position, col.transform.position);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = shardData.atlasIndex;
}
}
return closestIndex;
}
public Transform GetCurrentShardTransform(int index)
{
if (index < 0) return null;
foreach (var root in mapRoots)
{
if (root != null)
{
var shards = root.GetComponentsInChildren<ShardData>();
foreach (var shard in shards)
{
if (shard.atlasIndex == index)
return shard.transform;
}
}
}
return null;
}
public Texture GetTextureAtIndex(int index)
{
if (index < 0) return null;
foreach (var data in sculptureData)
{
if (data != null && index >= data.startIndex && index < data.startIndex + data.imageCount)
return data.atlasMaterial.mainTexture;
}
return null;
}
public Rect GetUVRectForIndex(int index)
{
if (index < 0) return new Rect(0, 0, 1, 1);
foreach (var data in sculptureData)
{
if (data != null && index >= data.startIndex && index < data.startIndex + data.imageCount)
{
int localIndex = index - data.startIndex;
if (localIndex >= 0 && localIndex < data.uvRects.Length)
return data.uvRects[localIndex];
}
}
return new Rect(0, 0, 1, 1);
}
// OpticalFlowManager가 필요로 하는 함수입니다.
public bool GetFlowInputTextures(int currentIndex,
out Texture sourceAtlas, out Rect sourceUV, out Transform sourceTransform,
out Texture destAtlas, out Rect destUV, out Transform destTransform)
{
sourceAtlas = null;
sourceUV = Rect.zero;
sourceTransform = null;
destAtlas = null;
destUV = Rect.zero;
destTransform = null;
if (currentIndex < 0) return false;
sourceAtlas = GetTextureAtIndex(currentIndex);
sourceUV = GetUVRectForIndex(currentIndex);
sourceTransform = GetCurrentShardTransform(currentIndex);
int nextIndex = currentIndex + 1;
destAtlas = GetTextureAtIndex(nextIndex);
destUV = GetUVRectForIndex(nextIndex);
destTransform = GetCurrentShardTransform(nextIndex);
return (sourceAtlas != null && destAtlas != null && sourceTransform != null && destTransform != null);
}
}
VFX Graph
Keijiro님의 `OpticalFlowTest` 예제를 기반으로, VFX Graph를 처음부터 끝까지 작성하는 방법.
이 그래프의 핵심은, 계산된 `FlowMap` 데이터를 바탕으로 각 파티클을 화살표처럼 만들고, 그 방향과 크기, 투명도를 실시간으로 제어하는 것입니다.
FlowMap의 정의
FlowMap은 '움직임 지도' 🗺️ 입니다.
일기예보에서 보는 바람 지도와 같습니다. 지도의 각 지점마다 화살표가 그려져 있어 바람의 '방향'과 '세기'를 알려주죠.
FlowMap도 똑같습니다. FlowMap 텍스처의 각 픽셀(점)은, 이전 이미지의 해당 픽셀이 현재 이미지에서 어느 방향(x, y)으로 얼마나 강하게 움직였는지를 색상(R, G 값)으로 저장하고 있습니다.)
최종 VFX Graph 작업 순서
2. VFX Graph 최종 설정
파라미터 (Blackboard)
- Texture2D FlowMap
- int GridWidth, int GridHeight
- Matrix4x4 ShardTransform_Start, Matrix4x4 ShardTransform_End
Spawn 컨텍스트
- OnEvent 'OnReinit' 모드의 Spawn 컨텍스트 안에 Single Burst를 사용합니다.
- Count는 GridWidth * GridHeight (multiply)로 설정합니다.
Initialize Particle 컨텍스트 (가장 중요)
- 로컬 좌표 계산: Get Particle ID, GridWidth, GridHeight와 수학 노드들을 이용해 정규화된(0~1) localPos (Vector2 또는 Vector3)를 계산합니다.
- 필요한 재료 3가지 준비
- Get Particle ID 노드
- GridWidth 파라미터
- GridHeight 파라미터
- X 좌표 계산
- Modulo (uint): A에는 Particle ID를, B에는 GridWidth를 연결합니다.
- float (변환): Modulo의 결과와 GridWidth를 각각 float 노드에 연결하여 소수로 만듭니다.
- Divide: Modulo에서 변환된 float 값을 GridWidth에서 변환된 float 값으로 나눕니다. 이것이 최종 X 좌표입니다.
- Y 좌표 계산
- float (변환): Particle ID와 GridWidth를 각각 float 노드로 변환합니다.
- Divide: Particle ID를 GridWidth로 나눕니다.
- Floor: 위 Divide 결과의 소수점을 버려 '행 번호'를 구합니다.
- Divide: Floor의 결과를 GridHeight(float으로 변환된)로 다시 나눕니다. 이것이 최종 Y 좌표입니다.
- 최종 결합
- Vector3 노드를 추가하고, X와 Y 입력에 각각 위에서 계산한 좌표들을 연결합니다. 이 노드의 출력이 바로 **localPos**입니다.
- 시작점 계산: localPos를 ShardTransform_Start로 변환하여 startPos를 계산하고 **Set Position**에 연결합니다.
- custom hlsl 노드 추가
- 필요한 재료 3가지 준비
void TransformLocalToWorld_float(float4x4 TransformMatrix, float3 LocalPosition, out float3 WorldPosition)
{
WorldPosition = mul(TransformMatrix, float4(LocalPosition, 1.0)).xyz;
}
- FlowMap 샘플링 및 도착점 로컬 좌표 계산 (핵심)
- FlowMap 샘플링: Sample Texture2D 노드로 **FlowMap**을 읽습니다.
- Sample Texture2D 노드를 추가하고, Texture에는 FlowMap 파라미터를, UV에는 1단계에서 만든 **localPos**를 연결합니다.
- Sample Texture의 s 출력(Vector4)을 **Swizzle (xy)**로 Vector2로 변환합니다. 이것이 flowVector입니다.
- (선택사항) flowVector의 값을 조절합니다. Multiply 노드로 FlowIntensity(새 float 파라미터)를 곱해 힘을 조절할 수 있습니다.
- Add 노드를 사용하여 localPos(Vector3)와 flowVector(Vector3로 변환)를 더합니다. 이것이 바로 endLocalPos, 즉 도착점의 로컬 좌표입니다.
4단계: 도착점 (endPos) 월드 좌표 계산
두 번째 Custom HLSL 블록을 사용하여, 3단계에서 계산한 **endLocalPos**를 ShardTransform_End 매트릭스로 변환합니다. 이 결과물(endPos)을 Set Target Position 블록에 연결합니다.
5단계: 최종 속도 (Velocity) 계산 및 설정
Subtract와 Divide 노드를 이용해 (endPos - startPos) / Lifetime 공식을 구현하고, 이 최종 결과물을 Set Velocity 블록에 연결합니다.
Update 컨텍스트
- 완전히 비워둡니다.
Output 컨텍스트
- 파티클 모양: Output 블록의 **Size**를 비균일하게 설정하여 '선'으로 만듭니다 (예: Vector2(0.02, 0.4)).
- 파티클 방향: **Orient Along Velocity**를 켜서 파티클이 계산된 속도 방향을 따라 자연스럽게 회전하도록 합니다.
안됨.
1️⃣ Custom HLSL: CalculateLocalPosition
입력: Get Particle ID, GridWidth, GridHeight
출력: LocalPos (Vector2)
2️⃣ Custom HLSL: TransformToStartWorld
입력: LocalPos, ShardTransform_Start
출력: StartWorldPos (Vector3)
3️⃣ Set Position
입력: StartWorldPos
4️⃣ Custom HLSL: SampleFlowMap
입력: LocalPos, FlowMap
출력: FlowVector (Vector2)
5️⃣ Custom HLSL: ApplyFlowVector
입력: LocalPos, FlowVector, FlowIntensity
출력: EndLocalPos (Vector2)
6️⃣ Custom HLSL: TransformToEndWorld
입력: EndLocalPos, ShardTransform_End
출력: EndWorldPos (Vector3)
7️⃣ Custom HLSL: CalculateVelocity
입력: StartWorldPos, EndWorldPos, Lifetime
출력: Velocity (Vector3)
8️⃣ Set Velocity
입력: Velocity
// ========== 1번째 HLSL: ParticleID → LocalPos 계산 ==========
void CalculateLocalPosition_float(
uint ParticleID,
int GridWidth,
int GridHeight,
out float2 LocalPos
)
{
// ParticleID를 2D 그리드 좌표로 변환 (0~1 범위)
LocalPos.x = (ParticleID % GridWidth) / (float)(GridWidth - 1);
LocalPos.y = floor(ParticleID / (float)GridWidth) / (float)(GridHeight - 1);
}
// ========== 2번째 HLSL: LocalPos + StartTransform → StartWorldPos ==========
void TransformToStartWorld_float(
float2 LocalPos,
float4x4 StartTransform,
out float3 StartWorldPos
)
{
// 로컬 좌표를 시작 Transform으로 변환
float3 localPos3D = float3(LocalPos, 0.0);
StartWorldPos = mul(StartTransform, float4(localPos3D, 1.0)).xyz;
}
// ========== 3번째 HLSL: FlowMap 샘플링만 ==========
void SampleFlowMap_float(
float2 LocalPos,
Texture2D FlowMap,
out float2 FlowVector
)
{
// FlowMap 샘플링
float4 flowData = FlowMap.SampleLevel(FlowMap.samplerstate, LocalPos, 0);
FlowVector = flowData.xy * 2.0 - 1.0; // 0~1을 -1~1로 변환
}
// ========== 4번째 HLSL: FlowVector 적용하여 EndLocalPos 계산 ==========
void ApplyFlowVector_float(
float2 LocalPos,
float2 FlowVector,
float FlowIntensity,
out float2 EndLocalPos
)
{
// 도착점 로컬 좌표 계산
EndLocalPos = LocalPos + FlowVector * FlowIntensity;
EndLocalPos = saturate(EndLocalPos); // 0~1 범위로 클램프
}
// ========== 4번째 HLSL: EndLocalPos + EndTransform → EndWorldPos ==========
void TransformToEndWorld_float(
float2 EndLocalPos,
float4x4 EndTransform,
out float3 EndWorldPos
)
{
// 도착점 로컬 좌표를 월드 좌표로 변환
float3 endLocalPos3D = float3(EndLocalPos, 0.0);
EndWorldPos = mul(EndTransform, float4(endLocalPos3D, 1.0)).xyz;
}
// ========== 5번째 HLSL: StartPos, EndPos → Velocity ==========
void CalculateVelocity_float(
float3 StartPos,
float3 EndPos,
float Lifetime,
out float3 Velocity
)
{
// 최종 속도 계산
Velocity = (EndPos - StartPos) / Lifetime;
}
Get Particle ID ─┬─→ CalculateLocalPosition ──→ LocalPos
GridWidth ──────┤ │
GridHeight ─────┘ │
│
ShardTransform_Start ──→ TransformToStartWorld ←─┤
│ │
↓ │
StartWorldPos │
│ │
↓ │
Set Position │
│
FlowMap ────────────→ SampleFlowMap ←───────────┤
│ │
↓ │
FlowVector │
│ │
FlowIntensity ──→ ApplyFlowVector ←──────────────┤
│ │
↓ │
EndLocalPos │
│ │
ShardTransform_End ──→ TransformToEndWorld ←─────┘
│
↓
EndWorldPos
│
┌────────────┴──────────────┐
↓ ↓
StartWorldPos EndWorldPos
│ │
└────→ CalculateVelocity ←──┘
│
Lifetime ──→ │
│
↓
Velocity
│
↓
Set Velocity
📋 Initialize Particle Context:
1️⃣ HLSL 체인들 (이미 만든 것들)
CalculateLocalPosition → TransformToStartWorld → StartWorldPos
CalculateLocalPosition → SampleFlowMap → ApplyFlowVector → TransformToEndWorld → EndWorldPos
2️⃣ Set Position
입력: StartWorldPos (Vector3)
기능: 파티클 시작 위치 설정
3️⃣ Set Target Position
입력: EndWorldPos (Vector3)
기능: 파티클 목표 위치 설정
4️⃣ CalculateVelocity HLSL
입력: StartWorldPos, EndWorldPos, Lifetime
출력: Velocity
5️⃣ Set Velocity
입력: Velocity (Vector3)
기능: 파티클 속도 설정
'Unity > VFX Graph' 카테고리의 다른 글
Unity_import pointcloud _keijiro_texture2D (0) | 2023.06.11 |
---|---|
unkity2021_ vfx Graph_ fbx motion (skined mesh) (0) | 2022.10.26 |
unity VFX graph asset (0) | 2022.10.14 |
Unity VFX Graph : 3d obj -> 2D texture map(position, color)/ SDF? (0) | 2022.10.14 |
Unity VFX Graph: script로 제어하기 (0) | 2022.10.13 |
- Total
- Today
- Yesterday
- RNN
- DeepLeaning
- Python
- CNC
- sequelize
- 4d guassian splatting
- VR
- Midjourney
- TouchDesigner
- Unity
- MQTT
- krea
- 라즈베리파이
- colab
- Express
- Java
- AI
- three.js
- 4dgs
- 유니티
- ai film
- Arduino
- node.js
- opencv
- 후디니
- docker
- houdini
- MCP
- opticalflow
- VFXgraph
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |