'가디언아카데미' 강화 무기 그래픽 효과 개발
'가디언 아카데미 - 방치형 가디언 키우기'의 강화 무기 그래픽 효과에 대한 개발 내용을 소개합니다.
※ 이 포스팅은 개발사와 공개 여부가 협의되어 작성되었습니다.
Sprite Outline Mask - ComputeShader 활용 마스크 텍스처 일괄 생성
Sprite 쉐이더에서 마스크 텍스처를 기반으로 아웃라인 색상을 표현합니다.
아래 자료와 같이 쉐이더에서 처리 하는 방법도 있지만 마스크 텍스처 방식을 사용함으로서 샘플링 비용을 줄이고 아웃라인이 내부 색상 표현까지 가능합니다.
하지만 수십개가 넘는 무기 스프라이트를 마스크 텍스처 생성하기에는 무리가 있어 Compute Shader를 활용해 자동으로 마스크 텍스처를 일괄 생성하는 툴을 제작했습니다.
Compute Shader에서 알파 채널을 기준으로 외각선 픽셀을 검출하여 RenderTexture에 렌더링하는 방식입니다.
아래는 Compute Shader 코드와 에디터 스크립트입니다.
Compute Shader 코드
#pragma kernel CSSpriteTextureOutline
RWTexture2D<float4> _Output;
uint IsOutofTextureSize(uint2 uv, uint2 textureSize)
{
if(uv.x <= 0 || uv.x >= textureSize.x) return 1;
if(uv.y <= 0 || uv.y >= textureSize.y) return 1;
return 0;
}
[numthreads(8, 8, 1)]
void CSSpriteTextureOutline (uint3 id : SV_DispatchThreadID)
{
const uint2 neighborOffsets[4] = { -1, 0,
1, 0,
0, -1,
0, 1};
float4 baseColor = float4(0, 0, 0, 1);
float4 outlineColor = float4(1, 1, 1, 1);
uint2 uv = id.xy;
uint2 sourceSize;
_Output.GetDimensions(sourceSize.x, sourceSize.y);
float4 centerPixel = _Output[id.xy];
baseColor.a = centerPixel.a;
[unroll]
for(int i=0; i<4; i++)
{
if(centerPixel.a < 0.5f) continue;
uint2 offset = neighborOffsets[i];
uint2 neighborPixelUV = uint2(uv.x + offset.x, uv.y + offset.y);
float4 neighborPixel = _Output[neighborPixelUV];
if (IsOutofTextureSize(neighborPixelUV, sourceSize) || neighborPixel.a < 1.0f)
{
_Output[uv.xy] = outlineColor;
return;
}
}
_Output[uv.xy] = baseColor;
}
에디터 스크립트
using System.IO;
using UnityEditor;
using UnityEngine;
namespace CatDarkGame
{
/// <summary>
/// SpriteOutlineGenerator 에디터 메뉴 및 핵심 기능 클래스
/// </summary>
public static class SpriteOutlineGenerator_MenuItem
{
private const string MENUITEM_BASEPATH = "Assets/Generate Sprite Outline";
// Project Window에서 에셋 우측 마우스 클릭시 메뉴 표시
[UnityEditor.MenuItem(MENUITEM_BASEPATH, false, 85)]
public static void GenerateSpriteOutline()
{
Object[] selectObjs = Selection.objects;
if (selectObjs.Length <= 0) return;
for(int i=0; i<selectObjs.Length; i++)
{
Object obj = selectObjs[i];
if (!obj || obj.GetType() != typeof(Texture2D)) return;
Texture2D selectTexture = obj as Texture2D;
Texture2D computedTexture = SpriteOutlineGenerator.ComputeSpriteOutline(selectTexture);
if (!computedTexture) return;
string generateAssetPath = SpriteOutlineGenerator.GetGenerateDirectoryPath(obj);
Texture2D resultTexture = SpriteOutlineGenerator.SaveImageFile(generateAssetPath, computedTexture);
SpriteOutlineGenerator.CopyTextureImporterSettings(selectTexture, resultTexture);
Debug.Log("Complete SpriteOutlineGenerator - " + generateAssetPath);
}
}
}
public static class SpriteOutlineGenerator
{
private const string NewAssetName = "{0}_Mask.{1}";
private const string FileExtension = "png";
// 스크립트 파일 에셋 경로 얻기
public static string GetScriptAssetNameToPath(string assetName, bool fullPath = false)
{
string[] paths = AssetDatabase.FindAssets($"t:Script {assetName}");
if (paths.Length <= 0)
{
Debug.LogError("Asset does not exist in the path - " + assetName);
return null;
}
string path = AssetDatabase.GUIDToAssetPath(paths[0]);
if (fullPath) return path;
return path.Substring(0, path.LastIndexOf("/"));
}
private const string CSAssetPath = "/Shader/CSSpriteTextureOutline.compute";
private const string KernelName = "CSSpriteTextureOutline";
private static readonly int Property_Output = Shader.PropertyToID("_Output");
public static Texture2D ComputeSpriteOutline(Texture2D texture)
{
RenderTexture renderTexture = new RenderTexture(texture.width, texture.height, 0, RenderTextureFormat.ARGB32);
renderTexture.useMipMap = false;
renderTexture.autoGenerateMips = false;
renderTexture.enableRandomWrite = true;
renderTexture.Create();
Graphics.Blit(texture, renderTexture);
// SpriteOutlineGenerator.cs 파일 기준 ComputeShader 상대 경로 얻기
string path = GetScriptAssetNameToPath("SpriteOutlineGenerator") + CSAssetPath;
ComputeShader computeShader = AssetDatabase.LoadAssetAtPath<ComputeShader>(path);
if (!computeShader)
{
Debug.LogError("ComputeShader Asset Not Fount - " + path);
return null;
}
// ComputeShader 호출
int kernelIndex = computeShader.FindKernel(KernelName);
computeShader.GetKernelThreadGroupSizes(kernelIndex, out uint threadGroupSizeX, out uint threadGroupSizeY, out _);
Vector2Int threadGroups = new Vector2Int(Mathf.CeilToInt((float)renderTexture.width / (float)threadGroupSizeX),
Mathf.CeilToInt((float)renderTexture.height / (float)threadGroupSizeY));
computeShader.SetTexture(kernelIndex, Property_Output, renderTexture);
computeShader.Dispatch(kernelIndex, threadGroups.x, threadGroups.y, 1);
// ComputeShader에서 처리된 텍스처 정보 생성
RenderTexture.active = renderTexture;
Texture2D newTexture = new Texture2D(renderTexture.width, renderTexture.height, TextureFormat.RGBA32, false);
newTexture.ReadPixels(new Rect(0, 0, renderTexture.width, renderTexture.height), 0, 0);
newTexture.Apply(true);
RenderTexture.active = null;
renderTexture.Release();
return newTexture;
}
// 텍스처 생성 경로 얻기
public static string GetGenerateDirectoryPath(Object obj)
{
string dirPath = AssetDatabase.GetAssetPath(obj);
if (dirPath == "")
dirPath = "Assets";
else if (System.IO.Path.GetExtension(dirPath) != "")
dirPath = dirPath.Replace(System.IO.Path.GetFileName(AssetDatabase.GetAssetPath(obj)), "");
string generateAssetName = string.Format(NewAssetName, obj.name, FileExtension);
return AssetDatabase.GenerateUniqueAssetPath(dirPath + "/" + generateAssetName);
}
// 텍스처 파일 저장
public static Texture2D SaveImageFile(string path, Texture2D texture2D)
{
byte[] _bytes = texture2D.EncodeToPNG();
File.WriteAllBytes(path, _bytes);
AssetDatabase.ImportAsset(path);
AssetDatabase.Refresh();
Texture2D asset = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
if (asset != null)
{
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer != null)
{
importer.textureType = TextureImporterType.Sprite;
importer.mipmapEnabled = false;
importer.filterMode = FilterMode.Point;
importer.SaveAndReimport();
AssetDatabase.Refresh();
}
}
return asset;
}
// 텍스처Import Setting 복사
public static void CopyTextureImporterSettings(Texture2D src, Texture2D dest)
{
string srcPath = AssetDatabase.GetAssetPath(src);
string destPath = AssetDatabase.GetAssetPath(dest);
AssetDatabase.ImportAsset(destPath);
AssetDatabase.Refresh();
TextureImporter srcImporter = AssetImporter.GetAtPath(srcPath) as TextureImporter;
TextureImporter destImporter = AssetImporter.GetAtPath(destPath) as TextureImporter;
TextureImporterSettings settings = new TextureImporterSettings();
srcImporter.ReadTextureSettings(settings);
destImporter.SetTextureSettings(settings);
destImporter.textureCompression = srcImporter.textureCompression;
destImporter.SaveAndReimport();
AssetDatabase.Refresh();
}
}
}
Gradient Texture Color Blending
Sprite 쉐이더에서 그라디언트 텍스처를 UV 스크롤하여 색상 블랜딩하는 기능입니다.
등급별로 알맞는 그라디언트 텍스처를 넣어서 활용 할 수 있으며 가로 방향 픽셀 정보만 사용하기 때문에 작은 사이즈의 무압축 포맷을 사용합니다.
Sprite Ghost Trail
Particle System기반의 Ghost Trail 효과입니다 무기에 사용된 마스크 텍스처를 활용합니다.
Shader Controller 컴포넌트
위에서 개발한 주요 쉐이더 기능을 컴포넌트에서 제어할 수 있는 스크립트입니다.
SpriteRenderer 오브젝트에 붙여서 사용하며 작업자가 간편하게 세팅 가능합니다.
MaterialPropertyBlock mpb = new MaterialPropertyBlock();
_spriteRenderer.GetPropertyBlock(mpb);
if(_mask_Sprite) mpb.SetTexture("_MaskTex", _mask_Sprite.texture);
mpb.SetColor("_MaskColor", _mask_Color);
mpb.SetFloat("_MaskIntensity", _mask_Intensity);
if (_gradient_Texture) mpb.SetTexture("_GradientTex", _gradient_Texture);
if (_gradient_Texture) mpb.SetFloat("_GradientIntensity", _gradient_Intensity);
if (_gradient_Texture) mpb.SetFloat("_GradientSpeed", _gradient_Speed);
_spriteRenderer.SetPropertyBlock(mpb);
MaterialPropertyBlock으로 쉐이더 프로퍼티를 제어하여 GPU Instancing 지원이 가능하지만 유니티 기본 SpriteRenderer는 GPU Instancing을 지원하지 않기 때문에 드로우콜을 줄이기 위해서는 SpriteRenderer를 자체 개발이 추가로 필요합니다.
Sprite Additive 쉐이더
기본 Sprite 쉐이더의 Additive Blend 기능을 추가했습니다. 발광효과가 필요한 경우 위와 같이 활용 가능합니다.