
๐ParticleOnSphere.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
#if UNITY_EDITOR
using UnityEditor;
#endif
[ExecuteAlways]
[AddComponentMenu("GenArt/Attractor Artwork")]
public class AttractorArtwork : MonoBehaviour
{
[Header("Spawn")]
[Tooltip("๋์์ ์กด์ฌํ ์ ์๋ ํํฐํด ์ต๋ ์. CPU ์๋ฎฌ๋ ์ด์
์ด๋ฏ๋ก 20000 ์ดํ ๊ถ์ฅ.")]
[Range(100, 20000)] public int maxCount = 5000;
[Tooltip("์ด๋น ํํฐํด ์์ฑ ์.")]
[Range(1f, 2000f)] public float spawnRate = 200f;
[Header("Sphere Attractor")]
[Tooltip("๋ชฉํ ๊ตฌ์ฒด์ ๋ฐ์ง๋ฆ.")]
[Range(0.1f, 10f)] public float sphereRadius = 1.97f;
[Tooltip("ํ๋ฉด์ ํฅํ ์ธ๋ ฅ ์๋ ์ค์ผ์ผ.")]
[Range(0.1f, 20f)] public float attractionSpeed = 5f;
[Tooltip("์ธ๋ ฅ ๊ฐ๋.")]
[Range(0f, 5f)] public float attractionForce = 0.78f;
[Tooltip("ํ๋ฉด์ ๋ฌ๋ผ๋ถ๊ธฐ ์์ํ๋ ๊ฑฐ๋ฆฌ.")]
[Range(0f, 3f)] public float stickDistance = 1.12f;
[Tooltip("ํ๋ฉด ๊ณ ์ ํ์ ๊ฐ๋.")]
[Range(0f, 5f)] public float stickForce = 1.08f;
[Header("Turbulence")]
[Tooltip("ํฐ๋ทธ๋ฐ์ค ์ธ๊ธฐ.")]
[Range(0f, 3f)] public float turbIntensity = 0.55f;
[Tooltip("ํฐ๋ทธ๋ฐ์ค ๋๋๊ทธ (์ง์ ๊ฐ์ ).")]
[Range(0f, 10f)] public float turbDrag = 2f;
[Tooltip("๋
ธ์ด์ฆ ๊ณต๊ฐ ์ฃผํ์. ํด์๋ก ์ธ๋ฐํ ํจํด.")]
[Range(0.1f, 10f)] public float turbFrequency = 2.11f;
[Tooltip("๋
ธ์ด์ฆ ์๊ฐ ํ๋ฆ ์๋.")]
[Range(0f, 2f)] public float turbTimeScale = 0.3f;
[Header("Particle Size")]
[Range(0.001f, 0.1f)] public float minSize = 0.008f;
[Range(0.001f, 0.15f)] public float maxSize = 0.035f;
[Header("Color by Speed")]
[Tooltip("์ ์ ํํฐํด ์์ (ํ๋ฉด์ ๋ถ์ ์ํ).")]
public Color slowColor = new Color(0.65f, 0.25f, 1.0f);
[Tooltip("๊ณ ์ ํํฐํด ์์ (๋น ๋ฅด๊ฒ ์ด๋ ์ค).")]
public Color fastColor = new Color(0.15f, 1.0f, 0.85f);
[Tooltip("fastColor์ ๋์ํ๋ ์๋ ์ํ. ์ด ์๋ ์ด์์ ๋ชจ๋ fastColor.")]
[Range(0.05f, 10f)] public float speedRangeMax = 1.5f;
private struct Particle
{
public Vector3 pos;
public Vector3 vel;
public float size;
public float speedMag;
}
private readonly List<Particle> _p = new();
private float _spawnTimer;
private float _time;
private Mesh _mesh;
private Material _mat;
private MaterialPropertyBlock _mpb;
private const int BATCH = 1023;
private readonly Matrix4x4[] _mBuf = new Matrix4x4[BATCH];
private readonly Vector4[] _cBuf = new Vector4[BATCH];
void OnEnable()
{
_p.Clear();
_spawnTimer = 0f;
_time = 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;
_time += dt;
Spawn(dt);
Simulate(dt);
}
Render();
#if UNITY_EDITOR
if (!Application.isPlaying)
SceneView.RepaintAll();
#endif
}
void InitResources()
{
if (_mesh == null)
_mesh = Resources.GetBuiltinResource<Mesh>("Sphere.fbx");
if (_mat == null)
{
var sh = Shader.Find("Custom/ShaderUnlit");
if (sh == null)
{
Debug.LogError("[AttractorArtwork] ์
ฐ์ด๋ 'Custom/ShaderUnlit'๊ฐ ์์ต๋๋ค. " +
"Assets/Shaders/SwirlUnlit.shader ๋ฅผ ํ๋ก์ ํธ์ ์ถ๊ฐํ์ธ์.");
return;
}
_mat = new Material(sh) { enableInstancing = true };
}
_mpb ??= new MaterialPropertyBlock();
}
void BuildEditorPreview()
{
_p.Clear();
int count = Mathf.Min(maxCount, 3000);
for (int i = 0; i < count; i++)
{
_p.Add(new Particle
{
pos = Random.onUnitSphere * sphereRadius,
vel = Vector3.zero,
size = Random.Range(minSize, maxSize),
speedMag = 0f
});
}
}
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;
Vector3 startPos = Random.insideUnitSphere * 0.05f;
if (startPos.sqrMagnitude < 1e-6f)
startPos = new Vector3(0.001f, 0f, 0f);
_p.Add(new Particle
{
pos = transform.position + startPos,
vel = Random.onUnitSphere * 0.15f,
size = Random.Range(minSize, maxSize),
speedMag = 0f
});
}
if (_spawnTimer > interval * 5f)
_spawnTimer = 0f;
}
void Simulate(float dt)
{
Vector3 center = transform.position;
float expDrag = Mathf.Exp(-turbDrag * dt);
for (int i = 0; i < _p.Count; i++)
{
var p = _p[i];
Vector3 fromCenter = p.pos - center;
float distFromC = fromCenter.magnitude;
Vector3 radialDir = distFromC > 0.001f ? fromCenter / distFromC : Vector3.up;
Vector3 surfacePoint = center + radialDir * sphereRadius;
Vector3 toSurface = surfacePoint - p.pos;
float distToSurf = toSurface.magnitude;
p.vel += toSurface.normalized * (attractionForce * attractionSpeed * dt);
if (distToSurf < stickDistance)
{
float t = 1f - (distToSurf / stickDistance);
p.vel += toSurface * (stickForce * t * dt);
}
p.vel += CurlNoise(p.pos, _time) * dt;
p.vel *= expDrag;
p.pos += p.vel * dt;
p.speedMag = p.vel.magnitude;
_p[i] = p;
}
}
Vector3 CurlNoise(Vector3 pos, float t)
{
float f = turbFrequency;
float tm = t * turbTimeScale;
float nx = N(pos.y * f, pos.z * f + tm) - N(pos.z * f, pos.y * f + tm);
float ny = N(pos.z * f, pos.x * f + tm) - N(pos.x * f, pos.z * f + tm);
float nz = N(pos.x * f, pos.y * f + tm) - N(pos.y * f, pos.x * f + tm);
return new Vector3(nx, ny, nz) * turbIntensity;
}
float N(float x, float y) => Mathf.PerlinNoise(x + 100f, y + 100f) - 0.5f;
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];
_mBuf[i] = Matrix4x4.TRS(p.pos, Quaternion.identity, Vector3.one * p.size);
float tc = Mathf.Clamp01(p.speedMag / Mathf.Max(speedRangeMax, 0.001f));
Color c = Color.Lerp(slowColor, fastColor, tc);
_cBuf[i] = new Vector4(c.r, c.g, c.b, 1f);
}
_mpb.Clear();
_mpb.SetVectorArray("_BaseColor", _cBuf);
Graphics.DrawMeshInstanced(_mesh, 0, _mat, _mBuf, n, _mpb);
offset += n;
}
}
#if UNITY_EDITOR
void OnValidate()
{
if (Application.isPlaying) return;
if (_mesh == null || _mat == null) InitResources();
if (_mesh != null && _mat != null)
{
BuildEditorPreview();
SceneView.RepaintAll();
}
}
void OnDrawGizmosSelected()
{
Gizmos.color = new Color(0.2f, 1f, 0.5f, 0.25f);
Gizmos.DrawWireSphere(transform.position, sphereRadius);
Gizmos.color = new Color(0.2f, 1f, 0.5f, 0.08f);
Gizmos.DrawSphere(transform.position, sphereRadius);
}
#endif
}
๐ShaderUnlit.shader
Shader "Custom/ShaderUnlit"
{
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"
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
}
}
}