

๋ธ๋ ๋์์ ๊ตฌํํ ์ ์ฌ ์ํธ์ํฌ
ํ์ผ ๋ฐฐ์น:
SwirlUnlit.shader โ Assets/Shaders/
SwirlArtwork.cs โ Assets/Scripts/
์ฌ์ ๋น GameObject์ SwirlArtwork ์ปดํฌ๋ํธ ์ถ๊ฐ
// SwirlArtwork.cs
// ๋์ ํ ์ค์ ํํฐํด ์ํธ์. [ExecuteAlways]๋ก ์๋ํฐ์์๋ ํ๋ฆฌ๋ทฐ ํ์.
// Assets/Scripts/ ํด๋์ ๋ฐฐ์น ํ ์ฌ์ ๋น GameObject์ ์ปดํฌ๋ํธ ์ถ๊ฐ.
// ์๊ตฌ์ฌํญ: Unity 6.0 + URP, Custom/SwirlUnlit ์
ฐ์ด๋ ํ์.
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.InputSystem;
#if UNITY_EDITOR
using UnityEditor;
#endif
[ExecuteAlways]
[AddComponentMenu("GenArt/Swirl Artwork")]
public class SwirlArtwork : MonoBehaviour
{
// === Spawn & Motion ===
[Header("Spawn & Motion")]
[Tooltip("ํํฐํด ์ต๋ ๊ฐ์. ์ด๊ณผ์ ๊ฐ์ฅ ์ค๋๋ ํํฐํด ํ๊ดด.")]
[Range(10, 2000)] public int maxCount = 1000;
[Tooltip("ํํฐํด์ด ํ๊ดด๋๋ ์ต๋ ๋์ด (Y์ถ).")]
[Range(1f, 30f)] public float maxHeight = 10f;
[Tooltip("์ด๋น ํํฐํด ์์ฑ ๊ฐ์.")]
[Range(1f, 60f)] public float spawnRate = 15f;
[Tooltip("ํํฐํด ์ต์ ์์น ์๋ (์ ๋/์ด).")]
[Range(0.02f, 3f)] public float minSpeed = 0.1f;
[Tooltip("ํํฐํด ์ต๋ ์์น ์๋ (์ ๋/์ด).")]
[Range(0.02f, 5f)] public float maxSpeed = 0.6f;
[Tooltip("ํํฐํด ์ต์ ํฌ๊ธฐ.")]
[Range(0.01f, 0.3f)] public float minSize = 0.04f;
[Tooltip("ํํฐํด ์ต๋ ํฌ๊ธฐ.")]
[Range(0.05f, 0.8f)] public float maxSize = 0.18f;
// === Spiral Shape ===
[Header("Spiral Shape")]
[Tooltip("๋์ ์ ๋ฐ์ง๋ฆ.")]
[Range(0.1f, 6f)] public float spiralRadius = 1.2f;
[Tooltip("maxHeight๊น์ง ๊ฐ๊ธฐ๋ ํ์ ์.")]
[Range(1f, 12f)] public float spiralTurns = 4f;
[Tooltip("๋์ ๊ฒฝ๋ก๋ก๋ถํฐ ๋๋ค ์ด๊ฒฉ ๋ฒ์ (spiralRadius์ ๋ฐฐ์จ).")]
[Range(0f, 1f)] public float jitterRange = 0.25f;
// === Color ===
[Header("Color")]
[Tooltip("๋์ด 0 (๋ฐ๋ฅ) ์ ์์.")]
public Color bottomColor = new Color(1f, 0.35f, 0.65f); // pink
[Tooltip("๋์ด maxHeight (๊ผญ๋๊ธฐ) ์ ์์.")]
public Color topColor = new Color(0.25f, 0.45f, 1f); // blue
// === Mouse Repulsion ===
[Header("Mouse Repulsion")]
[Tooltip("๋ง์ฐ์ค ๋ฐ๋ฐ ์ํฅ ๋ฐ๊ฒฝ (ํฝ์
๋จ์). ํ๋ฉด ํด์๋ ๊ธฐ์ค์ผ๋ก ์ง๊ด์ ์ผ๋ก ์กฐ์ .")]
[Range(20f, 600f)] public float repulsionRadius = 150f;
[Tooltip("๋ฐ๋ฐ๋ ฅ ์ต๋ ์ธ๊ธฐ.")]
[Range(1f, 60f)] public float repulsionStrength = 14f;
[Tooltip("๊ฐ์ฐ์์ ๋ถํฌ์ ์๊ทธ๋ง ๊ฐ (ํฝ์
). ํด์๋ก ๋ฐ๋ฐ์ด ๋์ ๋ฒ์์์ ๋ถ๋๋ฝ๊ฒ ํผ์ง.")]
[Range(10f, 400f)] public float gaussianSigma = 60f;
[Tooltip("๋์ ๊ฒฝ๋ก๋ก ๋ณต์ํ๋ ์คํ๋ง ๊ฐ๋.")]
[Range(1f, 100f)] public float springStrength = 28f;
[Tooltip("์๋ ๊ฐ์ ์์ (์ง์ ๊ฐ์ ). ํด์๋ก ๋น ๋ฅด๊ฒ ์ ์ง.")]
[Range(0.1f, 20f)] public float dampingDecay = 5f;
// ----- ๋ด๋ถ ๊ตฌ์กฐ์ฒด -----
private struct Particle
{
public Vector3 basePos; // ๋์ ๊ฒฝ๋ก ์์ ๋ชฉํ ์์น
public Vector3 pos; // ์ค์ ํ์ฌ ์์น (๋ฐ๋ฐ๋ก ์ดํ ๊ฐ๋ฅ)
public Vector3 vel; // ํ์ฌ ์๋
public float t; // 0~1 (๋์ด ์งํ๋)
public float speed; // t ๋จ์ ์๋ (= worldSpeed / maxHeight)
public float size; // ์คํผ์ด ์ค์ผ์ผ
public float radialJitter; // ๋ฐ์ง๋ฆ ๊ณ ์ ์คํ์
(์คํฐ์ ๊ฒฐ์ )
public float angularJitter; // ๊ฐ๋ ๊ณ ์ ์คํ์
(์คํฐ์ ๊ฒฐ์ )
}
// === ๋ฐํ์ ์ํ ===
private readonly List<Particle> _p = new();
private float _spawnTimer;
private Vector2 _mouseScreen;
private Mesh _mesh;
private Material _mat;
private MaterialPropertyBlock _mpb;
// DrawMeshInstanced ๋ฐฐ์น ์ต๋์น = 1023
private const int BATCH = 1023;
private readonly Matrix4x4[] _mBuf = new Matrix4x4[BATCH];
private readonly Vector4[] _cBuf = new Vector4[BATCH];
// === Unity ๋ผ์ดํ์ฌ์ดํด ===
void OnEnable()
{
_p.Clear();
_spawnTimer = 0f;
InitResources();
if (!Application.isPlaying)
BuildEditorPreview();
#if UNITY_EDITOR
SceneView.RepaintAll();
#endif
}
void OnDisable()
{
CoreUtils.Destroy(_mat);
_mat = null;
}
void Update()
{
if (_mat == null || _mesh == null) return;
if (Application.isPlaying)
{
float dt = Time.deltaTime;
Spawn(dt);
var _mouse = Mouse.current;
_mouseScreen = _mouse != null ? _mouse.position.ReadValue() : Vector2.zero;
Simulate(dt);
}
Render();
#if UNITY_EDITOR
if (!Application.isPlaying)
SceneView.RepaintAll();
#endif
}
// === ๋ฆฌ์์ค ์ด๊ธฐํ ===
// ๋ฉ์ยท๋จธํฐ๋ฆฌ์ผ ์์ฑ (์์ ๋๋ง ์คํ)
void InitResources()
{
if (_mesh == null)
{
// Resources API๋ก ๋ด์ฅ ๊ตฌ์ฒด ๋ฉ์ ์ง์ ์ฐธ์กฐ (GameObject ์์ฑ ๋ถํ์)
_mesh = Resources.GetBuiltinResource<Mesh>("Sphere.fbx");
}
if (_mat == null)
{
var sh = Shader.Find("Custom/SwirlUnlit");
if (sh == null)
{
Debug.LogError("[SwirlArtwork] ์
ฐ์ด๋ 'Custom/SwirlUnlit'๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค. " +
"Assets/Shaders/SwirlUnlit.shader ๊ฐ ํ๋ก์ ํธ์ ์๋์ง ํ์ธํ์ธ์.");
return;
}
_mat = new Material(sh) { enableInstancing = true };
}
_mpb ??= new MaterialPropertyBlock();
}
// === ์๋ํฐ ํ๋ฆฌ๋ทฐ ===
// ์ฌ์ ์ ์ง ์ํ์์ ๋์ ๋ถํฌ ์ค๋
์ท ์์ฑ
void BuildEditorPreview()
{
_p.Clear();
// ์ต๋ 600๊ฐ๋ก ์ ํํด ์๋ํฐ ๋ถํ ์ํ
int count = Mathf.Min(maxCount, 600);
for (int i = 0; i < count; i++)
{
float t = (float)i / count;
_p.Add(MakeParticle(t));
}
}
// === ํํฐํด ์์ฑ ===
// t (0~1) ์์น์ ํํฐํด ํ๋ ์์ฑ
Particle MakeParticle(float t)
{
var p = new Particle
{
t = t,
speed = Random.Range(minSpeed, maxSpeed) / Mathf.Max(maxHeight, 0.001f),
size = Random.Range(minSize, maxSize),
radialJitter = Random.Range(-jitterRange, jitterRange) * spiralRadius,
angularJitter = Random.Range(-jitterRange, jitterRange) * Mathf.PI
};
p.basePos = SpiralPos(p);
p.pos = p.basePos;
p.vel = Vector3.zero;
return p;
}
// tยทjitter ๊ฐ์ผ๋ก ๋์ ๊ธฐ์ค ์์น ๊ณ์ฐ
Vector3 SpiralPos(in Particle p)
{
float y = p.t * maxHeight;
float angle = p.t * spiralTurns * Mathf.PI * 2f + p.angularJitter;
float radius = spiralRadius + p.radialJitter;
return new Vector3(
radius * Mathf.Sin(angle),
y,
radius * Mathf.Cos(angle)
);
}
// === ์คํฐ ===
void Spawn(float dt)
{
if (_p.Count >= maxCount) return;
_spawnTimer += dt;
float interval = 1f / Mathf.Max(spawnRate, 0.001f);
while (_spawnTimer >= interval && _p.Count < maxCount)
{
_spawnTimer -= interval;
_p.Add(MakeParticle(0f));
}
// ๋์ ๋ฐฉ์ง: ํ๋ ์ ๋๋กญ์ผ๋ก ํ์ด๋จธ๊ฐ ํญ๋ฐํ๋ฉด ๋ฆฌ์
if (_spawnTimer > interval * 4f)
_spawnTimer = 0f;
}
// === ์๋ฎฌ๋ ์ด์
===
void Simulate(float dt)
{
// ํ๋ ์๋ ์ดํธ ๋
๋ฆฝ ์ง์ ๊ฐ์ : vel *= exp(-decay * dt)
float dampFactor = Mathf.Exp(-dampingDecay * dt);
var cam = Camera.main;
bool hasCam = cam != null;
for (int i = _p.Count - 1; i >= 0; i--)
{
var p = _p[i];
// ๋์ด ์งํ
p.t += p.speed * dt;
// ๋์ด ์ด๊ณผ ๋๋ maxCount ์ด๊ณผ์ ํ๊ดด
if (p.t >= 1f || _p.Count > maxCount)
{
_p.RemoveAt(i);
continue;
}
// ํ์ฌ t์ ๋ง๋ ๋์ ๊ธฐ์ค ์์น ๊ฐฑ์
p.basePos = SpiralPos(p);
// ๋์ ๊ฒฝ๋ก ๋ณต์ ์คํ๋ง ํ
p.vel += (p.basePos - p.pos) * (springStrength * dt);
// ๋ง์ฐ์ค ๋ฐ๋ฐ๋ ฅ: ์คํฌ๋ฆฐ ํฝ์
๊ฑฐ๋ฆฌ ๊ธฐ๋ฐ (์นด๋ฉ๋ผ ๊ฐ๋ ๋
๋ฆฝ)
if (hasCam)
{
Vector3 sp = cam.WorldToScreenPoint(p.pos);
if (sp.z > 0f) // ์นด๋ฉ๋ผ ์์ ์๋ ํํฐํด๋ง ์ฒ๋ฆฌ
{
float screenDist = Vector2.Distance(new Vector2(sp.x, sp.y), _mouseScreen);
if (screenDist < repulsionRadius && screenDist > 0.5f)
{
float g = Mathf.Exp(-(screenDist * screenDist)
/ (2f * gaussianSigma * gaussianSigma));
// ํํฐํด ๊น์ด ๊ธฐ์ค์ผ๋ก ๋ง์ฐ์ค๋ฅผ ์๋ ์ญํฌ์ โ ์ ํํ 3D ๋ฐ๋ฐ ๋ฐฉํฅ
Vector3 mWorld = cam.ScreenToWorldPoint(
new Vector3(_mouseScreen.x, _mouseScreen.y, sp.z));
Vector3 away = (p.pos - mWorld).normalized;
p.vel += away * (repulsionStrength * g * dt);
}
}
}
// ๊ฐ์ ์ ์ฉ ํ ์์น ์ ๋ถ
p.vel *= dampFactor;
p.pos += p.vel * dt;
_p[i] = p;
}
}
// === ๋ ๋๋ง ===
// 1023 ๋จ์ ๋ฐฐ์น๋ก ๋๋ DrawMeshInstanced ํธ์ถ
void Render()
{
if (_mat == null || _mesh == null || _p.Count == 0) return;
int total = _p.Count;
int offset = 0;
while (offset < total)
{
int n = Mathf.Min(BATCH, total - offset);
for (int i = 0; i < n; i++)
{
var p = _p[offset + i];
// TRS ๋งคํธ๋ฆญ์ค: ์์น + ๊ท ๋ฑ ์ค์ผ์ผ (ํ์ ๋ถํ์)
_mBuf[i] = Matrix4x4.TRS(p.pos, Quaternion.identity, Vector3.one * p.size);
// ๋์ด ๊ธฐ๋ฐ ์์ lerp: 0 โ bottomColor, maxHeight โ topColor
float tc = Mathf.Clamp01(p.pos.y / Mathf.Max(maxHeight, 0.001f));
Color c = Color.Lerp(bottomColor, topColor, tc);
_cBuf[i] = new Vector4(c.r, c.g, c.b, c.a);
}
_mpb.Clear();
_mpb.SetVectorArray("_BaseColor", _cBuf);
Graphics.DrawMeshInstanced(_mesh, 0, _mat, _mBuf, n, _mpb);
offset += n;
}
}
// SampleMouseWorld ์ ๊ฑฐ โ Simulate() ๋ด๋ถ์์ Mouse.current ์ง์ ์ฒ๋ฆฌ
// === ์๋ํฐ ํ
===
#if UNITY_EDITOR
// ์ธ์คํํฐ ๊ฐ ๋ณ๊ฒฝ์ ์๋ํฐ ํ๋ฆฌ๋ทฐ ์ฌ์์ฑ
void OnValidate()
{
if (Application.isPlaying) return;
// OnValidate๋ ์ง๋ ฌํ ๋์ค ํธ์ถ๋ ์ ์์ผ๋ฏ๋ก null ์ฒดํฌ ํ์
if (_mesh == null || _mat == null)
InitResources();
if (_mesh != null && _mat != null)
{
BuildEditorPreview();
SceneView.RepaintAll();
}
}
#endif
}
// SwirlUnlit.shader
// URP ์ธ๋ฆฟ ์
ฐ์ด๋. UNITY_INSTANCING_BUFFER๋ก ์ธ์คํด์ค๋ณ _BaseColor ์ง์.
// Assets/Shaders/ ํด๋์ ๋ฐฐ์น.
Shader "Custom/SwirlUnlit"
{
Properties
{
_BaseColor ("Base Color", Color) = (1, 1, 1, 1)
}
SubShader
{
Tags
{
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
"Queue" = "Geometry"
}
Pass
{
Name "SwirlForward"
Tags { "LightMode" = "UniversalForward" }
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
#pragma multi_compile_instancing
#pragma instancing_options assumeuniformscaling
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// === Per-Instance Property Buffer ===
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
struct Attributes
{
float4 posOS : POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 posHCS : SV_POSITION;
float4 color : COLOR0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
Varyings Vert(Attributes IN)
{
UNITY_SETUP_INSTANCE_ID(IN);
Varyings OUT;
UNITY_TRANSFER_INSTANCE_ID(IN, OUT);
OUT.posHCS = TransformObjectToHClip(IN.posOS.xyz);
OUT.color = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
return OUT;
}
half4 Frag(Varyings IN) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(IN);
return half4(IN.color);
}
ENDHLSL
}
}
}