동적 해상도와 업스케일 샤프닝을 프레임 예산에 맞춰 자동 조절하는 렌더링 로직

동적 해상도와 업스케일 샤프닝을 프레임 예산에 맞춰 자동 조절하는 렌더링 로직

60FPS 프레임 예산을 기준으로 GPU 시간을 측정하고 동적 해상도 스케일과 업스케일 샤프닝을 함께 조절하는 Unity·Unreal·C# 렌더링 로직을 수식과 코드로 구현합니다. EMA 필터와 히스테리시스로 안정적인 화질과 프레임 타임을 확보하는 방법을 설명합니다.

TL;DR

동적 해상도는 목표 프레임 시간보다 GPU 프레임 시간이 길어질 때 내부 렌더링 해상도를 낮춰 프레임을 회복하는 기법입니다. 반대로 GPU 여유가 충분하면 해상도를 조금씩 올립니다.

업스케일 샤프닝은 현재 해상도 스케일에 따라 제한적으로 조절해야 합니다. 해상도가 낮을수록 샤프닝 강도를 높이되 최대값을 제한하고 GPU 측정값에는 업스케일러와 샤프닝 비용까지 포함해야 안정적인 제어가 가능합니다.

동적 해상도는 왜 필요한가?

60FPS를 목표로 하는 게임의 이상적인 프레임 예산은 약 16.67ms입니다.

Bgpu=frac1000FtargetRreserveB_{gpu} = \\frac{1000}{F_{target}} - R_{reserve}

여기서 FtargetF_{target}은 목표 FPS이고 RreserveR_{reserve}는 순간적인 부하를 흡수하기 위한 여유 시간입니다. 60FPS에서 1.5ms를 예약하면 실제 GPU 예산은 약 15.17ms가 됩니다.

프레임 시간은 CPU와 GPU가 일부 구간에서 병렬로 실행되므로 단순히 모든 작업 시간을 더한 값과 같지는 않습니다. 따라서 동적 해상도 제어에는 주로 GPU 프레임 시간을 사용하고 CPU 프레임 시간은 별도로 감시해야 합니다.

GPU가 예산을 초과하면 다음과 같이 해상도 스케일을 조정할 수 있습니다.

snext=clamp(scurrent+Deltascdotsign(BgpuhatTgpu),smin,smax)s_{next} = clamp(s_{current} + \\Delta s \\cdot sign(B_{gpu} - \\hat{T}_{gpu}), s_{min}, s_{max})

hatTgpu\\hat{T}_{gpu}는 노이즈를 줄인 GPU 시간 추정값이고 Deltas\\Delta s는 한 번에 변경하는 해상도 스텝입니다. 실제 게임에서는 매 프레임 즉시 반응하기보다 EMA 필터와 히스테리시스를 함께 사용하는 편이 좋습니다.

해상도 스케일과 샤프닝 강도는 어떻게 연결할까?

현재 해상도 스케일을 기준으로 샤프닝 보정값을 계산합니다.

q=clamp(fracsmaxssmaxsmin,0,1)q = clamp(\\frac{s_{max} - s}{s_{max} - s_{min}}, 0, 1) h=lerp(hnative,hlowres,q)h = lerp(h_{native}, h_{lowres}, q)
  • ss: 현재 해상도 스케일
  • smins_{min}, smaxs_{max}: 허용할 최소·최대 스케일
  • hnativeh_{native}: 최대 해상도에서의 샤프닝 강도
  • hlowresh_{lowres}: 최소 해상도에서의 샤프닝 강도

예를 들어 최대 스케일이 1.0이고 최소 스케일이 0.67이라면 다음처럼 시작할 수 있습니다.

해상도 스케일샤프닝 강도 예시목적
1.000.15원본 해상도에서 과도한 링잉 방지
0.850.35가벼운 업스케일 보정
0.750.55중간 해상도 손실 보완
0.670.70최소 스케일에서 디테일 회복

샤프닝을 지나치게 높이면 밝은 경계 주변에 링잉이 생기거나 노이즈와 디더 패턴이 강조됩니다. 따라서 샤프닝은 해상도 손실을 숨기는 보조 수단으로 사용하고 최대값을 반드시 제한해야 합니다.

프레임 예산 기반 자동 조절 흐름

flowchart TD
    A[GPU 프레임 시간 샘플 수집] --> B[EMA 필터로 추정값 계산]
    B --> C{예산보다 느린가?}
    C -- 예 --> D[해상도 스케일 하향]
    C -- 아니오 --> E{충분한 여유가 있는가?}
    E -- 예 --> F[해상도 스케일 상향]
    E -- 아니오 --> G[현재 스케일 유지]
    D --> H[샤프닝 강도 재계산]
    F --> H
    G --> H
    H --> I[업스케일러와 렌더 타깃에 적용]

핵심은 세 가지입니다.

  1. GPU 측정값을 그대로 사용하지 않고 EMA로 평활화합니다.
  2. 예산 근처에서 해상도가 계속 오르내리지 않도록 히스테리시스를 둡니다.
  3. 해상도와 샤프닝을 같은 업데이트 주기에 적용하되 변경 폭은 작게 유지합니다.

Unity C#으로 동적 해상도 제어기를 구현하는 방법

Unity에서는 ScalableBufferManager.ResizeBuffers를 이용해 스케일 가능한 렌더 버퍼의 해상도를 조절할 수 있습니다. GPU 시간은 FrameTimingManager에서 읽을 수 있지만 결과가 지연되어 들어오거나 지원되지 않는 플랫폼에서는 0이 반환될 수 있습니다.

다음 예제는 60FPS를 기준으로 GPU 예산을 계산하고 4프레임마다 해상도와 샤프닝을 갱신합니다.

using UnityEngine;
using UnityEngine.Rendering;

public interface ISharpnessTarget
{
    void SetSharpness(float value);
}

public sealed class AdaptiveRenderBudget : MonoBehaviour
{
    [Header("Frame budget")]
    [SerializeField] private float targetFps = 60f;
    [SerializeField] private float reserveMs = 1.5f;
    [SerializeField] private float hysteresisMs = 0.6f;

    [Header("Resolution")]
    [SerializeField] private float minScale = 0.67f;
    [SerializeField] private float maxScale = 1.0f;
    [SerializeField] private float scaleStep = 0.05f;
    [SerializeField] private int decisionInterval = 4;

    [Header("Filtering")]
    [SerializeField] private float emaHalfLife = 0.25f;

    [Header("Upscale sharpness")]
    [SerializeField] private float sharpnessAtMaxScale = 0.15f;
    [SerializeField] private float sharpnessAtMinScale = 0.70f;
    [SerializeField] private MonoBehaviour sharpnessTarget;

    private readonly FrameTiming[] timingBuffer = new FrameTiming[1];
    private ISharpnessTarget sharpness;
    private float currentScale;
    private float emaGpuMs;
    private int framesSinceDecision;
    private bool hasSample;

    private float GpuBudgetMs => 1000f / Mathf.Max(1f, targetFps) - reserveMs;

    private void Awake()
    {
        sharpness = sharpnessTarget as ISharpnessTarget;
        currentScale = maxScale;
        ApplyQuality(currentScale);
    }

    private void Update()
    {
        if (!TryReadGpuTime(out float gpuMs))
            return;

        float alpha = 1f - Mathf.Exp(-Time.unscaledDeltaTime / Mathf.Max(0.01f, emaHalfLife));
        emaGpuMs = hasSample ? Mathf.Lerp(emaGpuMs, gpuMs, alpha) : gpuMs;
        hasSample = true;

        framesSinceDecision++;
        if (framesSinceDecision < decisionInterval)
            return;

        framesSinceDecision = 0;
        float nextScale = currentScale;
        float errorMs = GpuBudgetMs - emaGpuMs;

        if (errorMs < -hysteresisMs)
            nextScale -= scaleStep;
        else if (errorMs > hysteresisMs)
            nextScale += scaleStep;

        nextScale = Mathf.Clamp(nextScale, minScale, maxScale);
        nextScale = Mathf.Round(nextScale / scaleStep) * scaleStep;

        if (!Mathf.Approximately(nextScale, currentScale))
            ApplyQuality(nextScale);
    }

    private bool TryReadGpuTime(out float gpuMs)
    {
        gpuMs = 0f;
        FrameTimingManager.CaptureFrameTimings();

        uint count = FrameTimingManager.GetLatestTimings(1, timingBuffer);
        if (count == 0 || timingBuffer[0].gpuFrameTime <= 0.0)
            return false;

        gpuMs = (float)timingBuffer[0].gpuFrameTime;
        return true;
    }

    private void ApplyQuality(float scale)
    {
        currentScale = scale;
        ScalableBufferManager.ResizeBuffers(scale, scale);

        float range = Mathf.Max(0.0001f, maxScale - minScale);
        float normalizedLoss = Mathf.Clamp01((maxScale - scale) / range);
        float value = Mathf.Lerp(
            sharpnessAtMaxScale,
            sharpnessAtMinScale,
            normalizedLoss);

        sharpness?.SetSharpness(value);
    }
}

ISharpnessTarget는 프로젝트의 업스케일러 구현과 연결하기 위한 어댑터입니다. URP나 HDRP의 기본 기능이 런타임 샤프닝 값을 직접 노출하지 않는 경우에는 커스텀 렌더 패스, 업스케일 머티리얼, 또는 사용 중인 업스케일러의 런타임 파라미터에 연결합니다.

중요한 점은 FrameTimingManager의 샘플이 현재 프레임의 완전한 결과가 아닐 수 있다는 사실입니다. 실제 제품에서는 GPU 타임스탬프, 엔진 프로파일러의 비동기 샘플, 또는 여러 프레임을 저장하는 링 버퍼를 사용하는 편이 안전합니다.

Unreal Engine에서는 무엇을 연결해야 할까?

Unreal Engine에서는 엔진의 Dynamic Resolution 시스템과 충돌하지 않도록 한 곳에서 해상도 정책을 관리해야 합니다. 프로젝트에서 엔진 기본 동적 해상도를 사용한다면 별도의 로직이 r.ScreenPercentage를 계속 덮어쓰지 않도록 주의해야 합니다.

목적Unreal Engine 연결 지점주의사항
내부 렌더 해상도Dynamic Resolution 또는 Screen Percentage엔진 기본 동적 해상도와 중복 제어 금지
TSR 해상도 보정TSR 관련 품질 설정버전과 플랫폼별 지원 범위 확인
후처리 샤프닝Tonemapper Sharpen 또는 업스케일러 파라미터값이 높을수록 링잉과 노이즈 증가
GPU 시간 측정GPU profiler 및 프레임 타이밍 데이터지연 샘플과 GPU 대기 상태 고려

콘솔 변수 기반으로 테스트할 때는 r.ScreenPercentager.Tonemapper.Sharpen을 사용할 수 있지만 엔진 버전과 렌더링 경로에 따라 지원 여부와 권장 범위가 달라질 수 있습니다. 제품 코드에서는 문자열 기반 콘솔 변수 호출을 여러 시스템에 흩뿌리기보다 RenderQualityAdapter 같은 단일 어댑터를 두는 편이 좋습니다.

안정적인 자동 조절을 위한 구현 단계

1단계: 목표 프레임 예산을 정한다

목표 FPS만 정하지 말고 GPU 예산과 예약 시간을 함께 정합니다. 30FPS, 60FPS, 120FPS는 각각 약 33.33ms, 16.67ms, 8.33ms의 전체 프레임 시간을 가집니다. GPU에 전체 시간을 모두 배정하면 파티클, 그림자, 스트리밍 같은 순간 부하를 흡수하기 어렵습니다.

2단계: 측정값을 평활화한다

GPU 프레임 시간은 그림자 캐스케이드, 셰이더 컴파일, 파티클 생성 등에 의해 순간적으로 튈 수 있습니다. EMA는 다음과 같이 계산할 수 있습니다.

hatTt=alphaTt+(1alpha)hatTt1\\hat{T}_t = \\alpha T_t + (1 - \\alpha)\\hat{T}_{t-1}

짧은 반응이 필요한 액션 게임은 짧은 반감기를 사용하고 화면 품질의 안정성이 더 중요한 메뉴나 탐험 구간은 긴 반감기를 사용할 수 있습니다.

3단계: 히스테리시스와 변경 주기를 적용한다

예산이 15.17ms이고 히스테리시스가 0.6ms라면 GPU 추정값이 15.77ms를 넘을 때만 낮추고 14.57ms보다 낮을 때만 올립니다. 그 사이에서는 현재 해상도를 유지합니다.

이 구조는 다음과 같은 진동을 줄입니다.

  • 해상도 하향 직후 GPU 시간이 예산 안으로 들어옴
  • 다음 프레임에 즉시 해상도 상향
  • 다시 GPU 시간이 증가해 하향
  • 화면 스케일이 계속 바뀌며 화질이 흔들림

해상도 변경은 1프레임마다 수행하지 않고 3~8프레임 간격으로 제한하는 것이 일반적인 출발점입니다.

CPU 바운드 상황에서는 해상도를 낮추지 않는다

GPU 시간이 예산 이하인데도 전체 프레임 시간이 길다면 원인은 CPU일 수 있습니다. 이때 GPU 해상도를 낮춰도 게임 스레드, 애니메이션, 물리, UI 배치 비용은 줄지 않습니다.

따라서 다음 조건을 함께 확인해야 합니다.

상태GPU 시간CPU 시간권장 대응
GPU 바운드높음낮거나 보통동적 해상도 하향
CPU 바운드낮음높음게임 로직·UI·애니메이션 최적화
양쪽 모두 높음높음높음두 경로를 독립적으로 최적화
측정 불가0 또는 지연불명확보수적 기본값 유지

GPU 시간이 낮은데도 해상도를 계속 낮추는 상황을 막으려면 CPU 프레임 시간이나 FrameTimingManager의 CPU 시간도 함께 읽어 GPU 바운드 여부를 판정하는 것이 좋습니다.

샤프닝 변경 시 주의할 점

샤프닝은 무료한 보정 단계가 아닙니다. 풀스크린 패스와 텍스처 샘플링을 추가하므로 GPU 비용이 발생할 수 있습니다. 샤프닝을 적용한 뒤의 GPU 시간까지 다시 측정해야 제어기가 실제 비용을 반영합니다.

또한 샤프닝 값을 선형으로 크게 올리는 것보다 다음 정책이 안전합니다.

  • 최소 해상도에서 샤프닝 상한을 둔다.
  • UI와 텍스트 영역에는 별도의 샤프닝 정책을 적용한다.
  • TAA나 TSR의 히스토리와 충돌하는 과도한 선명화를 피한다.
  • 디버그 HUD에 현재 스케일, GPU EMA, 샤프닝 값을 표시한다.
  • 해상도 변경 로그를 저장해 특정 맵이나 이펙트에서의 변화를 확인한다.

특히 UI가 별도 해상도 또는 별도 카메라로 렌더링된다면 월드 렌더링의 동적 해상도와 UI 선명도를 분리해야 합니다. 모든 화면을 동일한 스케일로 낮추면 작은 글꼴과 아이콘이 먼저 손상됩니다.

자주 묻는 질문 FAQ

동적 해상도는 프레임 수를 직접 측정하면 안 되나요?

FPS보다 GPU 프레임 시간을 직접 사용하는 편이 좋습니다. FPS는 프레임 시간 변화에 비선형적으로 반응하지만 GPU 시간은 예산과 바로 비교할 수 있습니다.

해상도 스케일이 낮아질 때 샤프닝을 항상 높여야 하나요?

항상 그렇지는 않습니다. 업스케일러 종류, TAA 히스토리, 화면 콘텐츠에 따라 높은 샤프닝이 더 나쁜 결과를 만들 수 있습니다. 최소 스케일과 샤프닝 상한을 함께 테스트해야 합니다.

GPU 시간이 튈 때 해상도를 즉시 낮추면 더 빠르지 않나요?

단발성 스파이크에는 즉시 반응하지 않는 편이 안정적입니다. EMA, 히스테리시스, 최소 유지 시간을 사용하고 연속된 초과가 확인될 때만 낮추면 화면 깜빡임을 줄일 수 있습니다.

정리

프레임 예산 기반 동적 해상도는 단순히 FPS를 감시해 해상도를 낮추는 기능이 아닙니다. 목표 GPU 시간, 예약 여유, EMA 필터, 히스테리시스, 변경 주기를 하나의 제어 정책으로 묶어야 합니다.

샤프닝은 현재 해상도 손실을 보완하되 상한을 두고 GPU 비용까지 측정해야 합니다. Unity에서는 ScalableBufferManager와 업스케일러 어댑터를 연결하고 Unreal Engine에서는 Dynamic Resolution 시스템과 Screen Percentage 정책의 소유권을 명확히 하면 플랫폼별 렌더링 품질을 일관되게 관리할 수 있습니다.

#동적 해상도#업스케일링#샤프닝#Unity#Unreal Engine#C##렌더링 최적화

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs