티스토리 뷰
728x90
반응형
Unity Procedural interactiv 3D 종이접기 시스템 개발 과정
프로젝트 목표: Unity 환경에서 사용자가 동영상이 재생되는 평면(Plane)을 마치 실제 종이처럼 자유롭게 접고 상호작용할 수 있는 시스템을 개발한다.
1단계: 기본 아이디어 착안 - "영상이 적용된 종이를 접고 싶다"
- 초기 요구사항: Unity의 기본 Plane에 동영상 텍스처를 적용하고, 이 Plane을 종이접기처럼 접는 효과를 구현하는 것. 영상 이미지도 함께 접혀야 함.
- 초기 제안:
- C# 스크립트 방식: 코드(C#)로 직접 메쉬(Mesh)의 정점(Vertex) 위치를 계산하여 실시간으로 변형시키는 방법. 정밀한 제어가 가능.
- 쉐이더(Shader) 방식: GPU의 정점 쉐이더(Vertex Shader)를 이용해 정점을 변위시키는 방법. 성능상 이점이 있음.
2단계: 시간 차원 접기 - "물리적인 접기에서 개념적인 접기로"
- 아이디어 확장: 단순히 물리적인 종이를 접는 것을 넘어, 동영상이 가진 '시간'이라는 차원을 접는 독창적인 아이디어가 제안됨.
- 구현 방법론 제시:
- 시간 매핑(Time Mapping): VideoPlayer의 재생 시간(time)을 비선형적으로(예: 빨리 감기, 되감기, 핑퐁) 제어하여 시간이 압축되거나 늘어나는 효과 연출.
- 쉐이더 혼합: 서로 다른 두 시간대의 영상 프레임을 각각의 텍스처에 담아, 쉐이더 내에서 시각적으로 혼합(Blend)하는 방식.
- 데이터 텍스처(Time Map): 시간 정보를 담은 별도의 텍스처를 만들어, 각 픽셀이 어느 시간대의 프레임을 보여줄지 제어하는 고수준 기법.
3단계: 상호작용 구체화 - "사용자가 직접 필름 스트립을 접는 경험"
- 요구사항 구체화: 사용자가 마우스를 이용해 절차적으로, 그리고 시각적으로 명확하게 '시간의 나열(프레임의 배열)'을 접는 상호작용을 원함.
- 컨셉 확정: '시간의 필름 스트립'
- 동영상의 모든 프레임을 개별적인 2D 이미지 Plane으로 분리.
- 이 Plane들을 3D 공간에 길게 일렬로 배치하여 '필름 스트립'을 시각화.
- 사용자의 입력에 따라 이 스트립 전체를 아코디언처럼 접거나 나선형으로 감는 로직 제안.
4단계: 실질적인 프로토타입 개발 및 첫 번째 문제 봉착
- 1차 코드 제안 (Origami.cs):
- 절차적으로 고해상도 Plane 메시를 생성.
- '양면 종이' 효과를 위해 양면 쉐이더(TwoSidedOrigamiShader) 도입. 이 쉐이더는 카메라의 시점을 기준으로 픽셀의 앞/뒷면을 판단하여 서로 다른 텍스처나 색상을 보여주는 '눈속임' 방식.
- 사용자가 마우스로 두 점을 찍어 접기선을 정의하는 초기 인터랙션 구현 시도.
- 주요 문제점 발견:
- 좌표계 불일치: 월드 공간(World Space)과 로컬 공간(Local Space)의 개념을 혼용하여 계산에 오류 발생. 선이 공중에 뜨거나 엉뚱한 곳에 그려짐.
5단계: 문제 해결 및 기능 고도화 - "나이프 툴" 개념의 도입
수많은 오류 수정과 디버깅 과정을 거치며 시스템을 점차 완성시켜 나감.
- 뒷면 상호작용 문제 해결:
- 문제: 접힌 종이의 뒷면은 MeshCollider가 감지하지 못해 상호작용이 불가능했음.
- 해결: 눈에 보이는 **'시각적 메쉬(Visual Mesh)'**와 물리적 상호작용을 위한 **'콜라이더용 양면 메쉬(Collider Mesh)'**를 분리하여 생성. 이로써 어떤 각도에서도 마우스 클릭이 감지됨.
- 선과 면의 불일치 문제 해결 (나이프 툴 원리 도입):
- 문제: 마우스로 그은 선과 실제 접히는 면(가장 가까운 정점 기준)이 일치하지 않아 위화감이 발생.
- 해결: C4D나 블렌더의 '나이프 툴'에서 영감을 얻음. 마우스로 그은 선 자체를 로컬 좌표계의 **'가상 접기선'**으로 변환. 가장 가까운 정점을 찾는 대신, 이 가상의 선을 기준으로 모든 정점의 위치를 판단하여 접는 방식으로 변경하여 정확도를 극대화.
- 영상 비율 문제 해결:
- 문제: 고정된 Plane 크기로 인해 16:9 영상 재생 시 위아래에 검은 여백(레터박스)이 생김.
- 해결: 스크립트 시작 시, 비디오 클립의 메타데이터(가로/세로 해상도)를 비동기적으로 읽어온 후, 그 비율에 맞춰 종이(Plane)의 크기를 자동으로 조절하는 로직 추가.
- 사용자 경험(UX) 개선:
- 문제: 접기선이 즉시 생성되고 사라지는 과정이 딱딱했음.
- 해결: 코루틴(Coroutine)을 활용하여 시각적 흐름을 구현.
- 사용자가 마우스를 드래그하는 동안에는 노란색 임시선 표시.
- 마우스 버튼을 떼면, 선이 빨간색 확정선으로 잠시 변경됨.
- 잠시 후, 확정선은 사라지고 그 위치에 종이가 접히는 효과가 나타남.
- 일정 시간 후, 종이에 투사된 선마저 서서히 사라짐.
최종 결과물: 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
반응형
'Unity' 카테고리의 다른 글
Unity _ image sequence 조형물 생성 및 플레이 (6) | 2025.08.04 |
---|---|
Unity ML _ ai 학습 프로젝트 (2) | 2025.07.14 |
Unity_VR_2024ver_Oculus Quest 3_Meta SDK 패키지 (0) | 2024.08.02 |
Unity_real time _ audio thread : audioBuffer (0) | 2023.11.06 |
Unity_wav파일 Envelopes (0) | 2023.10.12 |
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- 4dgs
- Unity
- VFXgraph
- Midjourney
- ai film
- 라즈베리파이
- MCP
- opticalflow
- Python
- VR
- houdini
- three.js
- Express
- MQTT
- colab
- 유니티
- AI
- TouchDesigner
- Java
- sequelize
- opencv
- node.js
- krea
- Arduino
- CNC
- 후디니
- docker
- DeepLeaning
- RNN
- 4d guassian splatting
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
글 보관함
반응형