티스토리 뷰

Unity/VFX Graph

Unity_ OpticalFlow keijiro

잉_민 2025. 8. 8. 01:07
728x90
반응형

했다.

 

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개의 핵심 요소로 구성됩니다.

  1. OpticalFlowEstimator.cs: 두 이미지의 차이를 분석하여 '움직임 지도'인 FlowMap을 계산하는 공장장 역할을 합니다.
  2. PlaneOpticalFlowTest.cs: Unity 씬의 오브젝트들(Plane, VFX 등)을 관리하고, OpticalFlowEstimator에게 일하라고 명령을 내리는 현장 감독관 역할을 합니다.
  3. VFX Graph 에셋: C# 스크립트로부터 받은 데이터를 바탕으로, 수만 개의 파티클(선)을 화면에 실제로 그려내는 아티스트 역할을 합니다.

3. Unity 씬(Scene) 설정 (Step-by-Step) 🏗️

가장 먼저, 프로젝트를 구성할 모든 요소를 씬에 배치하고 서로 연결해야 합니다.

  1. Plane 오브젝트 2개 생성:
    • Hierarchy 창에서 우클릭 → 3D ObjectPlane을 선택하여 두 개를 만듭니다.
    • 각각의 이름을 Plane APlane B로 변경합니다.
    • 준비한 두 개의 순차적인 이미지(n번째, n+1번째)를 각각 Plane APlane B에 드래그 앤 드롭하여 머티리얼을 입힙니다.
  2. VFX Graph 에셋 및 오브젝트 생성:
    • Project 창에서 우클릭 → CreateVisual EffectsVisual Effect Graph를 선택하여 에셋을 만듭니다. 이름은 OpticalFlowVFX로 지정합니다.
    • Hierarchy 창에 빈 GameObject를 하나 만들고 이름을 VFX로 지정한 뒤, 방금 만든 OpticalFlowVFX 에셋을 드래그 앤 드롭합니다.
  3. Optical Flow 매니저 생성:
    • Hierarchy 창에 빈 GameObject를 하나 만들고 이름을 OpticalFlowManager로 지정합니다.
    • 아래 4단계에서 제공하는 OpticalFlowEstimator.cs 스크립트와, 프로젝트에 포함된 Flow.compute 셰이더를 이 오브젝트에 추가합니다.
  4. 최종 제어기(Controller) 설정:
    • Hierarchy 창에 빈 GameObject를 하나 더 만들고 이름을 MainController로 지정합니다.
    • 아래 4단계에서 제공하는 PlaneOpticalFlowTest.cs 스크립트를 이 MainController 오브젝트에 추가합니다.
    • MainController의 Inspector 창을 보면 여러 개의 빈 슬롯이 보입니다. 아래와 같이 각 슬롯에 맞는 오브젝트를 드래그 앤 드롭하여 연결해 주세요.
      • Plane A: HierarchyPlane A 오브젝트
      • Plane B: HierarchyPlane B 오브젝트
      • Optical Flow: HierarchyOpticalFlowManager 오브젝트
      • Visual Effect: HierarchyVFX 오브젝트

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 입력에 GridWidthGridHeightMultiply 노드로 곱한 값을 연결합니다.

C. Initialize (모든 마법이 일어나는 곳)

파티클이 생성되는 순간의 모든 속성을 설정합니다. 이 부분이 선의 '시작점'과 '끝점'을 정의하는 핵심 로직입니다.

  1. 파티클 수명 설정:
    • Set Lifetime 블록을 추가하고, 값을 3.0 정도로 설정합니다. (이 값이 0이면 오류 발생!)
  2. 첫 번째 Set Position 블록 (시작점 position 설정):
    • Set Position (Sequential 3D) 블록을 추가합니다.
    • 이 블록의 Origin, Axis X, Axis Y 입력값을 PlaneA_PositionPlaneA_Scale 데이터를 사용하여 정확히 설정합니다.
    • Write Position: 체크
    • Write Target Position: 체크 해제
  3. 시작 위치 저장 (아주 중요!):
    • Set StartPosition 블록을 추가합니다.
    • Get Attribute: position 노드를 만들어 Set StartPosition 블록에 연결합니다. 이로써 Update에서 덮어쓰기 되더라도 원본 시작 위치가 StartPosition에 안전하게 보존됩니다.
  4. TargetIndex 계산 (목표 지점의 순번 계산):
    • Get Attribute: particleId를 사용하여 LocalUV (0~1 범위 Vector2) 를 계산합니다.
    • LocalUVFlowMapSample Texture2D 노드로 샘플링하여 FlowOffset을 구합니다.
    • LocalUVFlowOffsetAdd 노드로 더해 EndUV를 만듭니다.
    • EndUV를 1차원 인덱스로 변환하여 TargetIndex 를 계산합니다. (Y * GridWidth + X)
  5. 두 번째 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 (선 그리기)

매 프레임마다 파티클의 위치를 업데이트하여 '선'처럼 보이게 만듭니다.

  1. Set Position 블록을 Update 컨텍스트에 추가합니다.
  2. Lerp (Vector3) 노드를 만듭니다.
  3. Lerp 노드의 입력을 아래와 같이 연결합니다:
    • A (출발점): Get StartPosition (Initialize에서 저장한 원본 위치)
    • B (도착점): Get Attribute: targetPosition
    • T (진행률): particle.ageparticle.lifetime으로 나눈 값
  4. Lerp 노드의 최종 출력값을 Set Position 블록에 연결합니다.

6. 실행 및 확인 🎉

모든 설정이 끝났습니다. 이제 Unity 에디터에서 Play 버튼을 누르세요. Plane A의 각 지점에서 시작하여, FlowMap이 알려주는 Plane B의 대응점을 향해 아름다운 선들이 그려지는 것을 확인하실 수 있을 겁니다!

 


 

실패의 기록

단순히 옵티컬 플로우 효과를 모방하는 것이 아니라, AI가 생성한 두 개의 개별적인 시공간(프레임 N과 프레임 N+1) 사이에서, 빛의 입자들이 자신의 정체성을 유지하며 다음 위치로 이동하는 **'정확한 경로'**를 3D 공간에 그려내는 것입니다.


## 3대 핵심 원칙

이 목표를 달성하기 위해, 우리의 시스템은 다음 세 가지 원칙을 반드시 지켜야 합니다.

  1. 1:1 픽셀 대응 (1-to-1 Pixel Correspondence) 시작 프레임(N)의 특정 좌표 (u,v)에 있던 파티클은, 옵티컬 플로우 FlowMap이 알려주는 변위 값에 따라, 도착 프레임(N+1)의 **정확히 대응되는 좌표 (u+dx, v+dy)**로 이동해야 합니다. 이것은 단순한 방향이 아닌, 정해진 목적지를 의미합니다.
  2. 3D 공간상의 변환 (Transformation in 3D Space) 이 1:1 대응은 2D 텍스처 공간에서만 일어나는 것이 아닙니다. 3D 공간에 배치된 시작 조각(ShardTransform_Start)의 표면에서, 3D 공간에 배치된 도착 조각(ShardTransform_End)의 표면까지의 입체적인 이동으로 구현되어야 합니다.
  3. 동적 및 연속성 (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 컨텍스트 (가장 중요)

  1. 로컬 좌표 계산: Get Particle ID, GridWidth, GridHeight와 수학 노드들을 이용해 정규화된(0~1) localPos (Vector2 또는 Vector3)를 계산합니다.
    1. 필요한 재료 3가지 준비
      1. Get Particle ID 노드
      2. GridWidth 파라미터
      3. GridHeight 파라미터
    2. X 좌표 계산
      1. Modulo (uint): A에는 Particle ID를, B에는 GridWidth를 연결합니다.
      2. float (변환): Modulo의 결과와 GridWidth를 각각 float 노드에 연결하여 소수로 만듭니다.
      3. Divide: Modulo에서 변환된 float 값을 GridWidth에서 변환된 float 값으로 나눕니다. 이것이 최종 X 좌표입니다.
    3. Y 좌표 계산
      1. float (변환): Particle ID와 GridWidth를 각각 float 노드로 변환합니다.
      2. Divide: Particle ID를 GridWidth로 나눕니다.
      3. Floor: 위 Divide 결과의 소수점을 버려 '행 번호'를 구합니다.
      4. Divide: Floor의 결과를 GridHeight(float으로 변환된)로 다시 나눕니다. 이것이 최종 Y 좌표입니다.
    4. 최종 결합
      1. Vector3 노드를 추가하고, X와 Y 입력에 각각 위에서 계산한 좌표들을 연결합니다. 이 노드의 출력이 바로 **localPos**입니다.
    5. 시작점 계산: localPos를 ShardTransform_Start로 변환하여 startPos를 계산하고 **Set Position**에 연결합니다.
      1. custom hlsl 노드 추가
void TransformLocalToWorld_float(float4x4 TransformMatrix, float3 LocalPosition, out float3 WorldPosition)
{
    WorldPosition = mul(TransformMatrix, float4(LocalPosition, 1.0)).xyz;
}

  1. FlowMap 샘플링 및 도착점 로컬 좌표 계산 (핵심)
    1. FlowMap 샘플링: Sample Texture2D 노드로 **FlowMap**을 읽습니다. 

톱니바퀴를 눌러 vector2로 전환

 

  • 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 컨텍스트

  1. 파티클 모양: Output 블록의 **Size**를 비균일하게 설정하여 '선'으로 만듭니다 (예: Vector2(0.02, 0.4)).
  2. 파티클 방향: **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)
   기능: 파티클 속도 설정
728x90
반응형
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/08   »
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
글 보관함
반응형