티스토리 뷰

728x90
반응형

 


Unity Procedural interactiv 3D 종이접기 시스템 개발 과정

프로젝트 목표: Unity 환경에서 사용자가 동영상이 재생되는 평면(Plane)을 마치 실제 종이처럼 자유롭게 접고 상호작용할 수 있는 시스템을 개발한다.

1단계: 기본 아이디어 착안 - "영상이 적용된 종이를 접고 싶다"

  • 초기 요구사항: Unity의 기본 Plane에 동영상 텍스처를 적용하고, 이 Plane을 종이접기처럼 접는 효과를 구현하는 것. 영상 이미지도 함께 접혀야 함.
  • 초기 제안:
    1. C# 스크립트 방식: 코드(C#)로 직접 메쉬(Mesh)의 정점(Vertex) 위치를 계산하여 실시간으로 변형시키는 방법. 정밀한 제어가 가능.
    2. 쉐이더(Shader) 방식: GPU의 정점 쉐이더(Vertex Shader)를 이용해 정점을 변위시키는 방법. 성능상 이점이 있음.

2단계: 시간 차원 접기 - "물리적인 접기에서 개념적인 접기로"

  • 아이디어 확장: 단순히 물리적인 종이를 접는 것을 넘어, 동영상이 가진 '시간'이라는 차원을 접는 독창적인 아이디어가 제안됨.
  • 구현 방법론 제시:
    1. 시간 매핑(Time Mapping): VideoPlayer의 재생 시간(time)을 비선형적으로(예: 빨리 감기, 되감기, 핑퐁) 제어하여 시간이 압축되거나 늘어나는 효과 연출.
    2. 쉐이더 혼합: 서로 다른 두 시간대의 영상 프레임을 각각의 텍스처에 담아, 쉐이더 내에서 시각적으로 혼합(Blend)하는 방식.
    3. 데이터 텍스처(Time Map): 시간 정보를 담은 별도의 텍스처를 만들어, 각 픽셀이 어느 시간대의 프레임을 보여줄지 제어하는 고수준 기법.

3단계: 상호작용 구체화 - "사용자가 직접 필름 스트립을 접는 경험"

  • 요구사항 구체화: 사용자가 마우스를 이용해 절차적으로, 그리고 시각적으로 명확하게 '시간의 나열(프레임의 배열)'을 접는 상호작용을 원함.
  • 컨셉 확정: '시간의 필름 스트립'
    • 동영상의 모든 프레임을 개별적인 2D 이미지 Plane으로 분리.
    • 이 Plane들을 3D 공간에 길게 일렬로 배치하여 '필름 스트립'을 시각화.
    • 사용자의 입력에 따라 이 스트립 전체를 아코디언처럼 접거나 나선형으로 감는 로직 제안.

4단계: 실질적인 프로토타입 개발 및 첫 번째 문제 봉착

  • 1차 코드 제안 (Origami.cs):
    • 절차적으로 고해상도 Plane 메시를 생성.
    • '양면 종이' 효과를 위해 양면 쉐이더(TwoSidedOrigamiShader) 도입. 이 쉐이더는 카메라의 시점을 기준으로 픽셀의 앞/뒷면을 판단하여 서로 다른 텍스처나 색상을 보여주는 '눈속임' 방식.
    • 사용자가 마우스로 두 점을 찍어 접기선을 정의하는 초기 인터랙션 구현 시도.
  • 주요 문제점 발견:
    1. 좌표계 불일치: 월드 공간(World Space)과 로컬 공간(Local Space)의 개념을 혼용하여 계산에 오류 발생. 선이 공중에 뜨거나 엉뚱한 곳에 그려짐.

5단계: 문제 해결 및 기능 고도화 - "나이프 툴" 개념의 도입

수많은 오류 수정과 디버깅 과정을 거치며 시스템을 점차 완성시켜 나감.

  • 뒷면 상호작용 문제 해결:
    • 문제: 접힌 종이의 뒷면은 MeshCollider가 감지하지 못해 상호작용이 불가능했음.
    • 해결: 눈에 보이는 **'시각적 메쉬(Visual Mesh)'**와 물리적 상호작용을 위한 **'콜라이더용 양면 메쉬(Collider Mesh)'**를 분리하여 생성. 이로써 어떤 각도에서도 마우스 클릭이 감지됨.
  • 선과 면의 불일치 문제 해결 (나이프 툴 원리 도입):
    • 문제: 마우스로 그은 선과 실제 접히는 면(가장 가까운 정점 기준)이 일치하지 않아 위화감이 발생.
    • 해결: C4D나 블렌더의 '나이프 툴'에서 영감을 얻음. 마우스로 그은 선 자체를 로컬 좌표계의 **'가상 접기선'**으로 변환. 가장 가까운 정점을 찾는 대신, 이 가상의 선을 기준으로 모든 정점의 위치를 판단하여 접는 방식으로 변경하여 정확도를 극대화.
  • 영상 비율 문제 해결:
    • 문제: 고정된 Plane 크기로 인해 16:9 영상 재생 시 위아래에 검은 여백(레터박스)이 생김.
    • 해결: 스크립트 시작 시, 비디오 클립의 메타데이터(가로/세로 해상도)를 비동기적으로 읽어온 후, 그 비율에 맞춰 종이(Plane)의 크기를 자동으로 조절하는 로직 추가.
  • 사용자 경험(UX) 개선:
    • 문제: 접기선이 즉시 생성되고 사라지는 과정이 딱딱했음.
    • 해결: 코루틴(Coroutine)을 활용하여 시각적 흐름을 구현.
      1. 사용자가 마우스를 드래그하는 동안에는 노란색 임시선 표시.
      2. 마우스 버튼을 떼면, 선이 빨간색 확정선으로 잠시 변경됨.
      3. 잠시 후, 확정선은 사라지고 그 위치에 종이가 접히는 효과가 나타남.
      4. 일정 시간 후, 종이에 투사된 선마저 서서히 사라짐.

최종 결과물: Origami_KnifeTool_Complete.cs

우리의 길었던 여정은 위의 모든 아이디어와 문제 해결 과정을 집대성한 하나의 완성된 시스템으로 귀결되었다. 이 시스템은 사용자의 직관적인 상호작용을 통해 3D 공간에서 동영상이 재생되는 종이를 물리적으로 정확하고 시각적으로 만족스럽게 접는 독창적인 경험을 제공한다.

 

using UnityEngine;
using UnityEngine.Video;
using UnityEngine.InputSystem;
using System.Collections;
using System.Collections.Generic;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer), typeof(MeshCollider))]
public class Origami_KnifeTool : MonoBehaviour
{
    #region 공개 변수
    [Header("종이 설정")]
    [Tooltip("이 값을 기준으로 종이의 해상도와 크기가 결정됩니다.")]
    public float paperScale = 10f;
    [Range(4, 100)] public int resolution = 60;
    
    [Header("상호작용 설정")]
    public float foldSpeed = 250f;
    
    [Header("비디오 설정")]
    public VideoClip videoClip;
    
    [Header("시각 효과")]
    public Material lineMaterial;
    public Color drawingLineColor = Color.yellow;
    public Color confirmedLineColor = Color.red;
    public Color paperBackColor = Color.white;
    #endregion

    #region 내부 변수
    private Mesh visualMesh, colliderMesh;
    private Vector3[] originalVertices, deformedVertices;
    private Vector2 paperSize;
    private List<Fold> folds = new List<Fold>();
    private int activeFoldIndex = -1;
    private bool isInteractionAllowed = true;
    private LineRenderer currentDrawingLine;
    #endregion

    #region Fold 클래스
    private class Fold
    {
        public Vector3 Axis { get; }
        public Vector3 Center { get; }
        public float Angle { get; set; }
        public List<int> VerticesToMove { get; }
        public LineRenderer LineVisual { get; set; }

        public Fold(Vector3 localStart, Vector3 localEnd, Vector3[] allVertices)
        {
            Angle = 0f;
            VerticesToMove = new List<int>();
            Axis = (localEnd - localStart).normalized;
            Center = (localStart + localEnd) * 0.5f;
            Vector3 perpendicular = Vector3.Cross(Axis, Vector3.up).normalized;

            for (int i = 0; i < allVertices.Length; i++)
            {
                if (Vector3.Dot(allVertices[i] - Center, perpendicular) > 0)
                {
                    VerticesToMove.Add(i);
                }
            }
        }
    }
    #endregion

    #region 초기화 및 업데이트
    IEnumerator Start()
    {
        yield return StartCoroutine(SetupVideoAndPaperSize());
        
        SetupMeshAndPhysics();
        SetupShader();
        GetComponent<MeshCollider>().sharedMesh = colliderMesh;
        Debug.Log("<color=lime><b>[Origami Knife Tool - Complete]</b> 준비 완료.</color>");
    }

    void Update()
    {
        if (!isInteractionAllowed) return;
        HandleMouseInput();
        HandleKeyboardInput();
    }
    #endregion

    #region 핵심 로직
    void ApplyAllFolds()
    {
        deformedVertices = (Vector3[])originalVertices.Clone();
        for (int foldIndex = 0; foldIndex < folds.Count; foldIndex++)
        {
            var fold = folds[foldIndex];
            if (Mathf.Approximately(fold.Angle, 0)) continue;
            
            Quaternion rotation = Quaternion.AngleAxis(fold.Angle, fold.Axis);
            foreach (int i in fold.VerticesToMove)
            {
                deformedVertices[i] = fold.Center + rotation * (deformedVertices[i] - fold.Center);
            }
        }
        visualMesh.vertices = deformedVertices;
        visualMesh.RecalculateNormals();
        colliderMesh.vertices = deformedVertices;
        GetComponent<MeshCollider>().sharedMesh = colliderMesh;
    }
    #endregion

    #region 입력 및 UX 흐름
    private void HandleMouseInput()
    {
        Mouse m = Mouse.current;
        if (m == null) return;
        Vector2 screenPos = m.position.ReadValue();

        if (m.leftButton.wasPressedThisFrame)
        {
            if (TryGetWorldPointOnPaper(screenPos, out Vector3 point))
            {
                StartDrawingLineVisual(point);
            }
        }
        else if (m.leftButton.isPressed && currentDrawingLine != null)
        {
            if (TryGetWorldPointOnPaper(screenPos, out Vector3 point))
            {
                UpdateDrawingLineVisual(point);
            }
        }
        else if (m.leftButton.wasReleasedThisFrame && currentDrawingLine != null)
        {
            StartCoroutine(ConfirmAndExecuteFold());
        }
    }

    private void HandleKeyboardInput()
    {
        Keyboard k = Keyboard.current;
        if (k == null) return;
        if (activeFoldIndex != -1)
        {
            if (k.qKey.isPressed) FoldActiveLine(1);
            if (k.eKey.isPressed) FoldActiveLine(-1);
            if (k.aKey.isPressed) FoldActiveLine(-1);
            if (k.dKey.isPressed) FoldActiveLine(1);
        }
        if (k.rKey.wasPressedThisFrame) ResetAllFolds();
        if (k.digit1Key.wasPressedThisFrame) SelectActiveFold(0);
        if (k.digit2Key.wasPressedThisFrame) SelectActiveFold(1);
    }

    private IEnumerator ConfirmAndExecuteFold()
    {
        isInteractionAllowed = false;

        currentDrawingLine.startColor = confirmedLineColor;
        currentDrawingLine.endColor = confirmedLineColor;
        
        Vector3 startWorld = currentDrawingLine.GetPosition(0);
        Vector3 endWorld = currentDrawingLine.GetPosition(1);
        Vector3 startLocal = transform.InverseTransformPoint(startWorld);
        Vector3 endLocal = transform.InverseTransformPoint(endWorld);

        Destroy(currentDrawingLine.gameObject);

        var newFold = new Fold(startLocal, endLocal, originalVertices);

        var projectedLine = CreateProjectedLine(newFold);
        newFold.LineVisual = projectedLine;
        folds.Add(newFold);
        SelectActiveFold(folds.Count - 1);

        yield return new WaitForSeconds(1.5f);
        if (projectedLine)
        {
            float t = 0;
            while (t < 0.5f)
            {
                float alpha = Mathf.Lerp(1, 0, t / 0.5f);
                projectedLine.startColor = new Color(confirmedLineColor.r, confirmedLineColor.g, confirmedLineColor.b, alpha);
                projectedLine.endColor = new Color(confirmedLineColor.r, confirmedLineColor.g, confirmedLineColor.b, alpha);
                t += Time.deltaTime;
                yield return null;
            }
            Destroy(projectedLine.gameObject);
        }

        isInteractionAllowed = true;
    }
    #endregion
    
    #region 유틸리티 및 설정 함수

    private LineRenderer CreateProjectedLine(Fold fold)
    {
        Vector3 start = fold.Center - fold.Axis * paperScale * 2;
        Vector3 end = fold.Center + fold.Axis * paperScale * 2;
        
        var lineObj = new GameObject($"FoldLine_{folds.Count}");
        lineObj.transform.SetParent(transform, false);
        var lr = lineObj.AddComponent<LineRenderer>();
        lr.material = lineMaterial;
        lr.useWorldSpace = false;
        lr.positionCount = 2;
        lr.startWidth = 0.02f; lr.endWidth = 0.02f;
        lr.startColor = confirmedLineColor; lr.endColor = confirmedLineColor;
        lr.sortingOrder = 5;
        lr.SetPosition(0, start);
        lr.SetPosition(1, end);
        return lr;
    }
    
    private IEnumerator SetupVideoAndPaperSize()
    {
        var vp = gameObject.AddComponent<VideoPlayer>();
        vp.playOnAwake = false; // 수동 제어
        if (videoClip != null)
        {
            vp.source = VideoSource.VideoClip;
            vp.clip = videoClip;
        }
        else
        {
            vp.source = VideoSource.Url;
            vp.url = System.IO.Path.Combine(Application.streamingAssetsPath, "01.mp4");
        }
        vp.isLooping = true;
        
        vp.Prepare(); // 비디오 로딩 시작

        // 비디오가 준비될 때까지 최대 5초 대기
        float timeout = 5f;
        while (!vp.isPrepared && timeout > 0)
        {
            timeout -= Time.deltaTime;
            yield return null;
        }

        if (vp.isPrepared)
        {
            float aspectRatio = (float)vp.width / vp.height;
            paperSize = new Vector2(paperScale, paperScale / aspectRatio);
        }
        else
        {
            Debug.LogWarning("비디오 로딩 실패 또는 시간 초과. 기본 16:9 비율로 설정합니다.");
            paperSize = new Vector2(paperScale, paperScale / (16f/9f));
        }
    }

    private void SetupShader()
    {
        var renderer = GetComponent<MeshRenderer>();
        var paperMaterial = new Material(Shader.Find("UltimateOrigami/TwoSidedPaper"));
        paperMaterial.SetColor("_BackColor", paperBackColor);
        renderer.material = paperMaterial;
        
        var vp = GetComponent<VideoPlayer>();
        if (vp.isPrepared)
        {
            var rt = new RenderTexture((int)vp.width, (int)vp.height, 24);
            vp.renderMode = VideoRenderMode.RenderTexture;
            vp.targetTexture = rt;
            paperMaterial.SetTexture("_MainTex", rt);
            vp.Play();
        }
    }

    private void SetupMeshAndPhysics()
    {
        visualMesh = new Mesh { name = "Visual Paper Mesh" };
        colliderMesh = new Mesh { name = "Collider Paper Mesh" };
        int xVerts = resolution + 1, yVerts = resolution + 1, vertCount = xVerts * yVerts;
        originalVertices = new Vector3[vertCount];
        var uvs = new Vector2[vertCount];
        for (int i = 0, y = 0; y < yVerts; y++) {
            for (int x = 0; x < xVerts; x++, i++) {
                float px = (float)x / resolution - 0.5f, py = (float)y / resolution - 0.5f;
                originalVertices[i] = new Vector3(px * paperSize.x, 0, py * paperSize.y);
                uvs[i] = new Vector2((float)x / resolution, (float)y / resolution);
            }
        }
        deformedVertices = (Vector3[])originalVertices.Clone();
        var visualTriangles = new List<int>();
        var colliderTriangles = new List<int>();
        for (int y = 0; y < resolution; y++) {
            for (int x = 0; x < resolution; x++) {
                int a = y * xVerts + x, b = a + xVerts, c = a + 1, d = a + xVerts + 1;
                visualTriangles.AddRange(new int[] { a, b, c, c, b, d });
                colliderTriangles.AddRange(new int[] { a, b, c, c, b, d });
                colliderTriangles.AddRange(new int[] { a, c, b, c, d, b });
            }
        }
        visualMesh.vertices = deformedVertices; visualMesh.uv = uvs; visualMesh.triangles = visualTriangles.ToArray(); visualMesh.RecalculateNormals();
        colliderMesh.vertices = deformedVertices; colliderMesh.triangles = colliderTriangles.ToArray(); colliderMesh.RecalculateNormals();
        GetComponent<MeshFilter>().mesh = visualMesh;
        GetComponent<MeshCollider>().sharedMesh = colliderMesh;
    }
    
    private void FoldActiveLine(float direction)
    {
        if (activeFoldIndex < 0 || activeFoldIndex >= folds.Count) return;
        folds[activeFoldIndex].Angle += direction * foldSpeed * Time.deltaTime;
        ApplyAllFolds();
    }
    
    private void SelectActiveFold(int index)
    {
        if (index < 0 || index >= folds.Count) return;
        activeFoldIndex = index;
    }
    
    private void ResetAllFolds()
    {
        foreach (var fold in folds) if(fold.LineVisual) Destroy(fold.LineVisual.gameObject);
        folds.Clear();
        activeFoldIndex = -1;
        ApplyAllFolds();
    }
    
    private bool TryGetWorldPointOnPaper(Vector2 screenPosition, out Vector3 hitPoint) 
    {
        Ray ray = Camera.main.ScreenPointToRay(screenPosition);
        if (Physics.Raycast(ray, out var hit, 1000f) && hit.collider.gameObject == gameObject) 
        { 
            hitPoint = hit.point; return true; 
        } 
        hitPoint = Vector3.zero; return false; 
    }
    
    private void StartDrawingLineVisual(Vector3 startPoint) 
    {
        currentDrawingLine = new GameObject("DrawingLine_Temp").AddComponent<LineRenderer>();
        currentDrawingLine.material = lineMaterial;
        currentDrawingLine.positionCount = 2;
        currentDrawingLine.startWidth = 0.05f; currentDrawingLine.endWidth = 0.05f;
        currentDrawingLine.startColor = drawingLineColor; currentDrawingLine.endColor = drawingLineColor;
        currentDrawingLine.useWorldSpace = true;
        currentDrawingLine.SetPosition(0, startPoint);
        currentDrawingLine.SetPosition(1, startPoint);
    }
    
    private void UpdateDrawingLineVisual(Vector3 endPoint) 
    { 
        if (currentDrawingLine) currentDrawingLine.SetPosition(1, endPoint); 
    }
    #endregion
}

 

쉐이더 코드

Shader "UltimateOrigami/TwoSidedPaper"
{
    Properties
    {
        _MainTex ("Video Texture (Front)", 2D) = "white" {}
        _BackColor ("Backside Color", Color) = (1, 1, 1, 1)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalRenderPipeline" }
        LOD 100
        
        Cull Off

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes
            {
                float4 positionOS   : POSITION;
                float2 uv           : TEXCOORD0;
            };

            struct Varyings
            {
                float4 positionHCS  : SV_POSITION;
                float2 uv           : TEXCOORD0;
                // VFACE를 vert에서 frag로 직접 전달하지 않습니다.
                // 대신 frag에서 isfrontface() 내장 함수를 사용합니다.
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _BackColor;

            Varyings vert (Attributes v)
            {
                Varyings o;
                o.positionHCS = TransformObjectToHClip(v.positionOS.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            // ⭐ 핵심 수정: 입력 구조체에 VFACE 대신, isfrontface() 내장 함수를 사용합니다.
            half4 frag (Varyings i, bool isFrontFace : SV_IsFrontFace) : SV_Target
            {
                // isFrontFace는 GPU가 직접 제공하는 bool 타입의 값입니다.
                // true이면 앞면, false이면 뒷면입니다.
                // 이 방식이 모든 플랫폼에서 가장 안정적입니다.
                if (isFrontFace)
                {
                    // 앞면일 경우: 비디오 텍스처를 샘플링
                    return tex2D(_MainTex, i.uv);
                }
                else
                {
                    // 뒷면일 경우: 지정된 단색을 출력
                    return _BackColor;
                }
            }
            ENDHLSL
        }
    }
}

 

728x90
반응형
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/09   »
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
글 보관함
반응형