본문 바로가기
Technical Art/Character Surface FX

캐릭터 스킬데칼 시스템 최적화

by jchan46 2026. 5. 6.

Skinned Decal System Optimization

1. 개요

이 작업은 캐릭터 피격 시 발생하는 스킨드 데칼 시스템의 비용을 줄이기 위한 최적화 작업이다.

기존 시스템은 피격 정보를 Render Target에 기록하고, 캐릭터 머티리얼의 HLSL Custom Node에서 해당 데이터를 순회하며 상처 마스크와 컬러를 합성하는 구조였다.

최적화 전에는 스킨데칼이 적용된 캐릭터 머티리얼의 Pixel Shader Instruction Count가 Epic 기준 986 instructions였다.
최적화 후에는 Epic 기준 911 instructions, Low 기준 841 → 765 instructions로 감소했다.

이번 최적화는 단순히 텍스처 샘플 수를 줄이는 작업이 아니라, 다음 세 가지 축으로 진행했다.

1. HLSL per-pixel 연산 비용 감소
2. RuntimeMaxDecals 기반 루프 상한 제어
3. C++ 런타임 업데이트 / Flush 스케줄링 최적화
 

2. 최적화 결과 요약

Pixel Shader Instruction Count 비교

QualityBeforeAfterReductionReduction Rate
Low 841 765 -76 약 -9.0%
Medium 847 772 -75 약 -8.9%
High 986 911 -75 약 -7.6%
Epic 986 911 -75 약 -7.6%

Texture Lookups / Sampler 변화

Texture Lookups:
Before: VS(4), PS(11 / 18 / 12 / 18)
After : VS(4), PS(11 / 18 / 12 / 18)

Samplers:
Before: 7 / 16
After : 7 / 16
 

Texture Lookups와 Sampler 수는 동일하게 유지되었다.
따라서 이번 최적화는 텍스처 샘플 수를 줄인 최적화가 아니라, per-pixel 산술 연산, 조건 분기, 불필요한 보조 경로를 줄인 최적화로 보는 것이 정확하다.


3. 기존 구조와 병목

기존 스킨드 데칼 HLSL은 각 픽셀마다 활성 데칼을 순회하며 다음 작업을 수행했다.

 
[loop]
for (int i = 0; i < LastValue; i++)
{
    // InfoTex에서 데칼 위치 / 회전 / 크기 / 스타일 데이터 읽기
    // 캐릭터 표면 위치와 데칼 중심 간 거리 계산
    // 데칼 로컬 UV 계산
    // 회전 / Stretch / SubUV 처리
    // 데칼 컬러 / 노멀 / 스펙 텍스처 샘플
    // 최종 마스크 / 컬러 / 노멀 합성
}
 

이 구조의 실제 비용은 다음과 같이 증가한다.

캐릭터가 화면에 차지하는 픽셀 수
× 활성 데칼 개수
× 데칼 투영 계산 비용
× 텍스처 샘플 비용
 

즉, 캐릭터가 화면에 크게 보이거나 데칼이 많이 누적될수록 Pixel Shader 부담이 커진다.


4. HLSL 최적화

4.1 사용하지 않는 Normal / Specular 경로 제거

기존에는 스타일별로 Normal / Specular 텍스처를 샘플링했다.

 
float4 DecalNormalGun   = Texture2DSample(DecalNormal_0, DecalNormal_0Sampler, UV);
float4 DecalNormalMelee = Texture2DSample(DecalNormal_1, DecalNormal_1Sampler, UV);
float4 DecalNormalTS    = (StyleIdx == 0) ? DecalNormalGun : DecalNormalMelee;

DecalNormalTS = UnpackNormalMap(DecalNormalTS);
float3 DecalNormalTangent = DecalNormalTS.xyz;

float4 SpecGun   = Texture2DSample(DecalSpec_0, DecalSpec_0Sampler, UV).a;
float4 SpecMelee = Texture2DSample(DecalSpec_1, DecalSpec_1Sampler, UV).r;

float4 SpecSample = (StyleIdx == 0) ? SpecGun : SpecMelee;
float SpecValue = SpecSample * SpecIntensity * DecalMask;
DecalSpecular = SpecValue;
 

그러나 현재 프로젝트의 스킨데칼 룩에서는 Normal / Specular 경로를 제거해도 비주얼 차이가 거의 없었다.

변경 후에는 데칼을 Color / Alpha 중심 구조로 정리했다.

 
float3 DecalNormalTangent = float3(0.0, 0.0, 1.0);
DecalSpecular = 0.0;
 

제거한 항목은 다음과 같다.

- DecalNormal_0 샘플
- DecalNormal_1 샘플
- DecalSpec_0 샘플
- DecalSpec_1 샘플
- Normal unpack 연산
- Spec intensity 계산
- 관련 Material Parameter 바인딩
 

4.2 회전 계산 C++ Precompute

기존에는 HLSL 내부에서 회전 각도를 받아 매 픽셀마다 radians, sin, cos를 계산했다.

 
float RotRad = radians(RotDeg);
float S = sin(RotRad);
float C = cos(RotRad);

float2 RotatedUV;
RotatedUV.x = UV.x * C - UV.y * S;
RotatedUV.y = UV.x * S + UV.y * C;
 

하지만 데칼 회전값은 데칼 생성 시점에 이미 결정되는 값이다.
따라서 픽셀마다 반복 계산할 필요가 없다.

변경 후에는 C++에서 데칼 생성 시점에 한 번만 계산한다.

 
const float RotRad = FMath::DegreesToRadians(RotDeg);
const float DecalRotSin = FMath::Sin(RotRad);
const float DecalRotCos = FMath::Cos(RotRad);
 

계산된 값은 데칼 payload에 저장한다.

 
Data.BasisY = FVector(DecalRotSin, DecalRotCos, DecalStretchEnable);
 

HLSL에서는 이미 계산된 값을 바로 사용한다.

 
float SinR = RotData.x;
float CosR = RotData.y;

float2 uvLocal = UV - 0.5;

float2 rotatedUV;
rotatedUV.x = uvLocal.x * CosR - uvLocal.y * SinR;
rotatedUV.y = uvLocal.x * SinR + uvLocal.y * CosR;

UV = rotatedUV + 0.5;
 

이 변경으로 HLSL에서 다음 비용을 제거했다.

- radians()
- sin()
- cos()
- 스타일별 회전 각도 계산 일부
 

핵심은 per-pixel 계산을 per-decal 계산으로 이동한 것이다.


4.3 Style 기반 Stretch 분기 최적화

기존 HLSL에서는 특정 스타일에만 Stretch 효과를 적용하기 위해 StyleIdx를 직접 비교했다.

 
if (StyleIdx == 1 || StyleIdx == 2 || StyleIdx == 3 || StyleIdx == 6)
{
    const float StretchTime = 0.7;
    float t = saturate(Age / StretchTime);

    float HeightScale = lerp(0.1, 1.0, t);

    float2 uvLocal = UV - 0.5;
    uvLocal.y /= HeightScale;
    UV = uvLocal + 0.5;
}
 

하지만 “어떤 스타일이 Stretch 대상인가”는 게임 규칙에 가깝다.
픽셀 셰이더가 매번 판단하기보다 C++에서 데칼 생성 시점에 한 번 계산하는 것이 더 적합하다.

C++에서 Stretch 적용 여부를 미리 계산했다.

 
const float DecalStretchEnable =
    (StyleIndex == 1 || StyleIndex == 2 || StyleIndex == 3 || StyleIndex == 6)
        ? 1.0f
        : 0.0f;
 

이 값을 RotData.z에 저장했다.

 
Data.BasisY = FVector(DecalRotSin, DecalRotCos, DecalStretchEnable);
 

HLSL에서는 단순 플래그만 확인한다.

 
float StretchEnable = RotData.z;

if (StretchEnable > 0.5)
{
    const float StretchTime = 0.7;
    float t = saturate(Age / StretchTime);

    float HeightScale = lerp(0.1, 1.0, t);

    float2 uvLocal = UV - 0.5;
    uvLocal.y /= HeightScale;
    UV = uvLocal + 0.5;
}
 

변경 전후 구조는 다음과 같다.

Before:
HLSL에서 StyleIdx 다중 비교

After:
C++에서 StretchEnable 1회 계산
HLSL은 payload flag만 사용
 

4.4 Style별 Color Texture 샘플링 정리

기존에는 총기 / 근접 데칼 텍스처를 모두 샘플한 뒤, StyleIdx에 따라 하나를 선택하는 구조였다.

 
float4 DecalSampleGun   = Texture2DSample(DecalColor_0, DecalColor_0Sampler, UV);
float4 DecalSampleMelee = Texture2DSample(DecalColor_1, DecalColor_1Sampler, UV);

float4 DecalSample = (StyleIdx == 0) ? DecalSampleGun : DecalSampleMelee;
 

변경 후에는 StyleIdx에 따라 필요한 텍스처만 샘플하도록 구조를 정리했다.

 
float4 DecalSample = float4(0, 0, 0, 0);

[branch]
if (StyleIdx == 0)
{
    DecalSample = Texture2DSample(DecalColor_0, DecalColor_0Sampler, UV);
}
else
{
    DecalSample = Texture2DSample(DecalColor_1, DecalColor_1Sampler, UV);
}
 

이 변경은 Platform Stats에서 Texture Lookups 감소로 명확하게 보이지 않을 수 있다.
하지만 구조적으로는 현재 스타일에 필요한 경로만 실행되도록 분리한 것이다.


5. RuntimeMaxDecals 기반 루프 상한 최적화

5.1 LoopCap 방식 시도와 폐기

처음에는 HLSL에서 최근 N개 데칼만 검사하도록 루프를 직접 제한하는 방식을 테스트했다.

 
const int ActiveDecalCount = min((int)LastValue, (int)MaxValues);
const int TestLoopCap = 12;
const int LoopCount = min(ActiveDecalCount, TestLoopCap);
const int StartDecalIndex = max(ActiveDecalCount - LoopCount, 0);

for (int LoopIdx = 0; LoopIdx < LoopCount; LoopIdx++)
{
    const int i = StartDecalIndex + LoopIdx;
}
 

하지만 이 방식은 현재 데이터 구조와 맞지 않았다.

스킨데칼 인덱스는 시간순 배열이 아니라 링버퍼 구조다.

 
Data.Index = (SamplerState.LastDecalIndex + 1) % MaxDecals;
 

즉, 배열 뒤쪽 인덱스가 항상 최신 데칼이라는 보장이 없다.

0번 슬롯 = 오래된 데칼일 수도 있음
0번 슬롯 = 방금 덮어쓴 최신 데칼일 수도 있음
31번 슬롯 = 최신일 수도 있고 오래된 것일 수도 있음
 

따라서 HLSL에서 단순히 뒤쪽 N개만 검사하면 최신 데칼이 누락되는 문제가 발생했다.

결론적으로 이 방식은 폐기했다.

HLSL에서 임의로 루프 범위를 자르는 방식은 현재 링버퍼 구조와 맞지 않음.
최근 데칼만 정확히 검사하려면 별도의 RecentIndexBuffer가 필요함.
 

5.2 RuntimeMaxDecals 도입

최종적으로는 HLSL 루프를 억지로 자르지 않고, C++에서 품질 단계별 최대 데칼 개수를 줄여 LastValue 자체를 낮추는 방식으로 변경했다.

품질 단계별 목표값은 다음과 같다.

Low     = 8
Medium  = 12
High    = 20
Epic    = 32
 

기존 MaxDecals는 에디터 설정값으로 유지하고, 런타임용 값을 별도로 추가했다.

 
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Performance")
int MaxDecals = 20;

UPROPERTY(Transient)
int32 RuntimeMaxDecals = 20;
 

Scalability의 EffectsQuality를 기준으로 RuntimeMaxDecals를 결정한다.

 
int32 USkinnedDecalSampler::GetRuntimeMaxDecalsByQuality() const
{
    if (!bUseQualityMaxDecals)
    {
        return FMath::Max(1, MaxDecals);
    }

    const int32 EffectsQuality = Scalability::GetQualityLevels().EffectsQuality;

    switch (EffectsQuality)
    {
    case 0: // Low
        return FMath::Max(1, LowMaxDecals);

    case 1: // Medium
        return FMath::Max(1, MediumMaxDecals);

    case 2: // High
        return FMath::Max(1, HighMaxDecals);

    case 3: // Epic
    default:
        return FMath::Max(1, EpicMaxDecals);
    }
}
 

5.3 RenderTarget 크기 동기화

스킨데칼 시스템은 데칼 1개당 5개의 데이터를 Render Target에 저장한다.

Position
BasisX
BasisY / Rotation Payload
BasisZ / Style Payload
Info
 

따라서 데이터 RT의 가로 크기는 다음과 같다.

RT Width = RuntimeMaxDecals * 5
 

기존에는 고정 MaxDecals를 사용했다.

 
DataTarget = UKismetRenderingLibrary::CreateRenderTarget2D(
    this,
    MaxDecals * 5,
    1,
    RTF_RGBA16f,
    FLinearColor::Black,
    false
);
 

수정 후에는 RuntimeMaxDecals를 사용한다.

 
DataTarget = UKismetRenderingLibrary::CreateRenderTarget2D(
    this,
    RuntimeMaxDecals * 5,
    1,
    RTF_RGBA16f,
    FLinearColor::Black,
    false
);
 

검증 결과는 다음과 같다.

Epic:
RuntimeMaxDecals = 32
RT Width = 160

Low:
RuntimeMaxDecals = 8
RT Width = 40
 

5.4 Material Parameter 동기화

HLSL의 UV 계산은 DecalMax 값을 기준으로 이루어진다.

 
const float ValuePercent = 1.0 / MaxValues;
const float2 InfoUV = (i * 5 + 0.5) * ValuePercent;
 

따라서 Material Parameter의 DecalMax도 RuntimeMaxDecals와 일치해야 한다.

기존:

 
DynamicMaterial->SetScalarParameterValueByInfo(
    FMaterialParameterInfo("DecalMax", Association, LayerIndex),
    MaxDecals * 5
);
 

수정 후:

 
DynamicMaterial->SetScalarParameterValueByInfo(
    FMaterialParameterInfo("DecalMax", Association, LayerIndex),
    static_cast<float>(RuntimeMaxDecals * 5)
);
 

다음 항목은 반드시 같은 기준을 사용해야 한다.

RuntimeMaxDecals
→ RenderTarget Width
→ Material DecalMax
→ Ring Buffer Index
→ DecalLast
→ HLSL Loop Range
 

5.5 링버퍼 인덱스 수정

RT 크기만 RuntimeMaxDecals 기준으로 줄이고, 링버퍼가 기존 MaxDecals 기준으로 동작하면 Low 품질에서 문제가 발생할 수 있다.

예를 들어 Low에서 RT는 8개용인데, 링버퍼가 20이나 32까지 인덱스를 생성하면 RT 범위 밖 데이터를 참조하게 된다.

기존:

 
Data.Index = (SamplerState.LastDecalIndex + 1) % MaxDecals;
 

수정 후:

 
const int32 EffectiveMaxDecals = FMath::Max(1, RuntimeMaxDecals);

Data.Index = Index;

if (Index >= EffectiveMaxDecals)
{
    return -1;
}

if (Index < 0)
{
    while (SamplerState.EmptyIndexes.Num() > 0)
    {
        const int32 CandidateIndex = SamplerState.EmptyIndexes[0];

        if (CandidateIndex >= 0 && CandidateIndex < EffectiveMaxDecals)
        {
            break;
        }

        SamplerState.EmptyIndexes.RemoveAt(0, 1, EAllowShrinking::No);
    }

    if (SamplerState.EmptyIndexes.IsValidIndex(0))
    {
        Data.Index = SamplerState.EmptyIndexes[0];
        SamplerState.EmptyIndexes.RemoveAt(0, 1, EAllowShrinking::No);
    }
    else
    {
        Data.Index = (SamplerState.LastDecalIndex + 1) % EffectiveMaxDecals;
    }

    SamplerState.LastDecalIndex = Data.Index;
}
 

핵심은 단순히 % MaxDecals를 % RuntimeMaxDecals로 바꾸는 것만이 아니다.
EmptyIndexes에 이전 품질 단계에서 생성된 큰 인덱스가 남아 있을 수 있으므로, RuntimeMaxDecals 범위를 벗어난 빈 슬롯은 제거해야 한다.


5.6 DecalLast Clamp

HLSL 루프 상한으로 사용되는 DecalLast도 RuntimeMaxDecals 기준으로 제한했다.

기존:

 
const int32 NewCount = SamplerState.DecalLocations.Num();
 

수정 후:

 
const int32 NewCount =
    FMath::Min(SamplerState.DecalLocations.Num(), RuntimeMaxDecals);
 

머티리얼에는 제한된 값을 전달한다.

 
MID->SetScalarParameterValueByInfo(
    FMaterialParameterInfo("DecalLast", Association, LayerIndex),
    static_cast<float>(NewCount)
);
 

이렇게 하면 HLSL 루프는 기존 구조를 유지하면서도 품질 단계에 따라 자연스럽게 제한된다.

 
[loop]
for (int i = 0; i < LastValue; i++)
{
    // decal projection
    // mask calculation
    // color blending
}
 

결과적으로 Low에서는 LastValue가 최대 8까지만 들어가고, Epic에서는 최대 32까지 들어간다.


5.7 MinDecalDistance 검사 범위 제한

데칼 중복 생성을 막기 위한 거리 검사도 RuntimeMaxDecals 범위까지만 수행하도록 수정했다.

기존:

 
for (int16 i = 0; i < SamplerState.DecalLocations.Num(); ++i)
{
    ...
}
 

수정 후:

 
const int32 DecalLocationCount =
    FMath::Min(SamplerState.DecalLocations.Num(), EffectiveMaxDecals);

for (int32 i = 0; i < DecalLocationCount; ++i)
{
    if (i == Index) continue;

    const float DistSq =
        (Data.DecalLocation - SamplerState.DecalLocations[i]).SizeSquared();

    if (DistSq < MinDecalDistance * MinDecalDistance)
    {
        return Index;
    }
}
 

6. MID Parameter Update 최적화

6.1 문제

기존 구조에서는 데칼이 추가되거나 Flush될 때마다 모든 MID에 DecalLast 값을 다시 세팅했다.

 
MID->SetScalarParameterValueByInfo(
    FMaterialParameterInfo("DecalLast", Association, LayerIndex),
    static_cast<float>(NewCount)
);
 

하지만 RuntimeMaxDecals가 가득 찬 이후에는 DecalLast 값이 더 이상 증가하지 않는다.

예를 들어 Low 품질에서는 다음과 같은 상황이 발생한다.

RuntimeMaxDecals = 8

8개까지:
DecalLast 0 → 1 → 2 → ... → 8

8개 이후:
슬롯은 덮어쓰지만 DecalLast는 계속 8 유지
 

이 상황에서 매번 동일한 값을 MID에 다시 전달하는 것은 불필요한 CPU 비용이다.


6.2 DecalLast 캐싱

마지막으로 제출한 DecalLast 값을 저장하기 위해 캐시 변수를 추가했다.

 
UPROPERTY(Transient)
int32 LastSubmittedDecalLast = INDEX_NONE;
 

현재 유효한 DecalLast를 계산하는 함수도 추가했다.

 
int32 USkinnedDecalSampler::GetEffectiveDecalLast() const
{
    return FMath::Min(SamplerState.DecalLocations.Num(), RuntimeMaxDecals);
}
 

그리고 값이 바뀐 경우에만 MID를 업데이트하도록 정리했다.

 
void USkinnedDecalSampler::UpdateDecalLastParameterIfNeeded(bool bForce)
{
    const int32 NewDecalLast = GetEffectiveDecalLast();

    if (!bForce && LastSubmittedDecalLast == NewDecalLast)
    {
        return;
    }

    LastSubmittedDecalLast = NewDecalLast;

    for (UMaterialInstanceDynamic* MID : Materials)
    {
        if (!IsValid(MID))
        {
            continue;
        }

        MID->SetScalarParameterValueByInfo(
            FMaterialParameterInfo("DecalLast", Association, LayerIndex),
            static_cast<float>(NewDecalLast)
        );
    }
}
 

적용 위치는 다음과 같다.

SpawnDecalFromData:
UpdateDecalLastParameterIfNeeded();

FlushBatchedDecals:
UpdateDecalLastParameterIfNeeded();

SetupComponentMaterials:
UpdateDecalLastParameterIfNeeded(true);

ClearAllDecals:
LastSubmittedDecalLast = INDEX_NONE;
UpdateDecalLastParameterIfNeeded(true);
 

6.3 구조 비교

기존 구조

SpawnDecal / Flush
→ DecalLast 계산
→ Materials 배열 순회
→ 모든 MID에 DecalLast 세팅
→ 값이 같아도 매번 업데이트
 

최적화 구조

SpawnDecal / Flush
→ Effective DecalLast 계산
→ 이전에 제출한 값과 비교
→ 값이 다를 때만 Materials 배열 순회
→ MID 업데이트
 

이 최적화는 Platform Stats에는 나타나지 않지만, 런타임 CPU / Material Parameter Update 비용을 줄이는 작업이다.


7. On-demand Flush 최적화

7.1 문제

기존에는 데칼 데이터를 배칭한 뒤 0.016초마다 FlushAllSkinnedDecals()를 반복 호출했다.

 
World->GetTimerManager().SetTimer(
    FlushTimerHandle,
    this,
    &UR2FxManager::FlushAllSkinnedDecals,
    0.016f,
    true
);
 

이 구조는 안정적이지만, 데칼이 없는 상황에서도 약 60FPS 주기로 Flush 함수가 계속 호출된다.

데칼 없음
→ PendingFlushSamplers 비어 있음
→ 그래도 FlushAllSkinnedDecals 호출
→ 빈 체크 후 return
 

실제 RT 업데이트나 GPU 비용이 발생하는 것은 아니지만, 불필요한 CPU 함수 호출과 타이머 체크가 발생한다.


7.2 개선 구조

Flush 등록 경로를 하나로 통합했다.

AddCharacterSkinnedDecal()
→ EnqueueSkinnedDecalFlush(SDS)
→ PendingFlushSamplers 등록
→ On-demand 모드일 경우 RequestSkinnedDecalFlush()
→ 0.016초 뒤 FlushAllSkinnedDecals() 1회 실행
 

공용 코드 안정성을 위해 기존 반복 타이머를 제거하지 않고 옵션화했다.

 
FTimerHandle SkinnedDecalFlushTimerHandle;

bool bSkinnedDecalFlushScheduled = false;

// false = 기존 반복 타이머 방식
// true  = 데칼 발생 시에만 Flush 예약
bool bUseOnDemandSkinnedDecalFlush = false;
 

Initialize에서는 옵션에 따라 기존 반복 타이머를 유지한다.

 
if (UWorld* World = GetWorld())
{
    if (!bUseOnDemandSkinnedDecalFlush)
    {
        World->GetTimerManager().SetTimer(
            FlushTimerHandle,
            this,
            &UR2FxManager::FlushAllSkinnedDecals,
            0.016f,
            true
        );
    }
}
 

7.3 Enqueue 함수

 
void UR2FxManager::EnqueueSkinnedDecalFlush(USkinnedDecalSampler* SDS)
{
    if (!IsValid(SDS))
    {
        return;
    }

    PendingFlushSamplers.Add(SDS);

    if (bUseOnDemandSkinnedDecalFlush)
    {
        RequestSkinnedDecalFlush();
    }
}
 

PendingFlushSamplers는 TSet<USkinnedDecalSampler*>를 사용한다.
따라서 같은 캐릭터가 같은 프레임에 여러 번 피격되어도 동일한 Sampler는 중복 등록되지 않는다.


7.4 Request 함수

 
void UR2FxManager::RequestSkinnedDecalFlush()
{
    if (!bUseOnDemandSkinnedDecalFlush)
    {
        return;
    }

    if (bSkinnedDecalFlushScheduled)
    {
        return;
    }

    UWorld* World = GetWorld();
    if (!World)
    {
        return;
    }

    bSkinnedDecalFlushScheduled = true;

    World->GetTimerManager().SetTimer(
        SkinnedDecalFlushTimerHandle,
        this,
        &UR2FxManager::FlushAllSkinnedDecals,
        0.016f,
        false
    );
}
 

bSkinnedDecalFlushScheduled를 통해 Flush 타이머 중복 예약을 방지했다.

첫 번째 데칼:
Request Flush Timer

같은 짧은 구간의 추가 데칼:
이미 예약됨
→ 추가 타이머 생성 안 함
 

7.5 Flush 처리

 
void UR2FxManager::FlushAllSkinnedDecals()
{
    bSkinnedDecalFlushScheduled = false;

    if (PendingFlushSamplers.Num() <= 0)
    {
        return;
    }

    const int32 FlushCount = PendingFlushSamplers.Num();

    TArray<USkinnedDecalSampler*> SamplersToFlush;
    SamplersToFlush.Reserve(FlushCount);

    for (USkinnedDecalSampler* SDS : PendingFlushSamplers)
    {
        SamplersToFlush.Add(SDS);
    }

    PendingFlushSamplers.Empty();

    for (USkinnedDecalSampler* SDS : SamplersToFlush)
    {
        if (IsValid(SDS))
        {
            SDS->FlushBatchedDecals();
            SDS->bIsBatching = false;
        }
    }
}
 

PendingFlushSamplers를 바로 순회하지 않고 로컬 배열로 복사한 뒤 비우는 이유는, Flush 도중 새 데칼 요청이 들어오는 경우를 안전하게 처리하기 위해서다.

Flush 시작
→ 현재 Pending 목록을 로컬 배열로 복사
→ PendingFlushSamplers 비움
→ 복사된 Sampler만 처리
→ Flush 도중 새로 들어온 요청은 다음 Flush로 넘어감
 

7.6 검증 로그

총기와 근접 공격 모두 다음 로그 흐름을 확인했다.

[SkinnedDecal] Request Flush
[SkinnedDecal] Flush Count = 1
 

근접 공격에서는 Style / Attack / Special 값이 정상적으로 기록되었다.

[SW] Item=20203 Style=3 Max=4 Attack=0 Special=0
[SkinnedDecal] Request Flush
[SkinnedDecal] Flush Count = 1

[SW] Item=20203 Style=3 Max=4 Attack=1 Special=0
[SkinnedDecal] Request Flush
[SkinnedDecal] Flush Count = 1

[SW] Item=20203 Style=3 Max=4 Attack=2 Special=1
[SkinnedDecal] Request Flush
[SkinnedDecal] Flush Count = 1
 

검증 기준은 다음과 같다.

데칼이 없을 때:
Request Flush 로그 없음
Flush Count 로그 없음

단일 피격:
Request Flush
Flush Count = 1

빠른 연속 피격:
이미 예약된 경우 추가 타이머 생성 없음

여러 몬스터 동시 피격:
Flush Count = 2 또는 3
 

8. 최종 구조

C++ 담당

- EffectsQuality 확인
- RuntimeMaxDecals 결정
- DataTarget 크기 생성
- DecalMax / DecalLast Material Parameter 설정
- 링버퍼 인덱스 RuntimeMaxDecals 기준으로 제한
- EmptyIndexes 범위 검증
- MinDecalDistance 검사 범위 제한
- DecalLast Parameter Update 캐싱
- On-demand Flush 스케줄링
 

HLSL 담당

- InfoTex에서 데칼 데이터 읽기
- 표면 위치와 데칼 중심 거리 계산
- 데칼 로컬 UV 생성
- C++에서 전달된 sin/cos로 회전 적용
- C++에서 전달된 StretchEnable으로 Stretch 적용
- 필요한 컬러 텍스처 샘플
- DecalLast까지만 안전하게 루프
- 최종 decal / mask 합성
 

9. 최종 평가

강점

- Pixel Shader Instruction Count 약 7.6~9.0% 감소
- 비주얼 유지
- per-pixel 회전 계산 제거
- Style 기반 분기를 C++ payload로 이동
- RuntimeMaxDecals 기반 품질별 루프 상한 제어
- RT Width / DecalMax / DecalLast / RingBuffer 기준 동기화
- MID Parameter Update 캐싱
- On-demand Flush 옵션화
- 기존 Legacy Flush 구조와 호환성 유지
 

한계

- Texture Lookups와 Sampler 수는 감소하지 않음
- Platform Stats만으로 실제 GPU ms 개선을 증명할 수는 없음
- 실제 런타임 개선 검증을 위해 stat gpu / profilegpu / Unreal Insights 측정 필요
- RecentIndexBuffer 기반 최신 데칼 우선 루프 최적화는 아직 미구현
 

실무적 판단

이번 최적화는 단순히 머티리얼 노드를 줄이는 작업이 아니라, C++ / HLSL / RenderTarget / Material Parameter / Scalability / Flush Scheduling을 함께 정리한 시스템 최적화다.

특히 HLSL에서 게임 규칙을 판단하던 부분을 C++로 이동하고, 픽셀마다 반복되던 계산을 데칼 생성 시점의 1회 계산으로 바꾼 점이 핵심이다.


10. 최종 요약

스킨드 데칼 시스템의 최적화는 HLSL 내부 연산 감소와 C++ 런타임 구조 개선을 함께 진행했다.

HLSL에서는 사용하지 않는 Normal / Specular 경로를 제거하고, 데칼 회전 계산에 필요한 sin/cos를 C++에서 미리 계산해 payload로 전달했다. 또한 Style 기반 Stretch 판단을 C++에서 처리해 HLSL은 단순 플래그만 확인하도록 변경했다.

루프 최적화에서는 HLSL에서 임의로 최근 N개만 검사하는 방식은 링버퍼 구조와 맞지 않아 폐기했다. 대신 RuntimeMaxDecals를 도입해 품질 단계별로 최대 데칼 개수를 제한하고, RenderTarget Width, DecalMax, DecalLast, RingBuffer Index를 모두 동일한 기준으로 동기화했다.

C++ 측면에서는 DecalLast 값이 변할 때만 MID를 업데이트하도록 캐싱했으며, Flush 처리도 기존 반복 타이머 방식과 On-demand 방식을 옵션으로 분리했다. On-demand 모드에서는 데칼이 발생한 경우에만 1회 Flush를 예약하여, 데칼이 없는 상태에서 불필요한 Flush 호출이 발생하지 않도록 했다.

최종적으로 Pixel Shader Instruction Count는 Epic 기준 986 → 911, Low 기준 841 → 765로 감소했다. Texture Lookups와 Sampler 수는 유지되었으므로, 이번 최적화는 텍스처 샘플 수 감소가 아니라 per-pixel 산술 연산, 조건 분기, 루프 상한, C++ 업데이트 비용을 함께 줄인 시스템 최적화다.

'Technical Art > Character Surface FX' 카테고리의 다른 글

캐릭터 스킨 데칼 시스템 구현기  (0) 2026.04.22