TECHARTNOMAD | TECHARTFLOWIO.COM

TECH.ART.FLOW.IO

[번역] Custom Bloom Post-Process in Unreal Engine by Froyok

jplee 2023. 10. 16. 14:20

역자의 말

Froyok 은 2014년 부터 2015년 까지 알레고리드믹에서 함께 일 하던 멋진 동료 입니다.

대학을 졸업하고 알레고리드믹에서 인턴쉽으로 그는 커리어를 시작하였고 현재는 시니어 프러덕트 메니저로서 여전히 서브스턴스 제품 개발과 함께 하고있는 정말 멋진 테크니컬 아티스트 입니다.

거의 3년 전에 그는 블룸 효과의 사용자화 기법에 대한 멋진 기사를 자신의 블로그에 게시 했습니다.

최근 역자는 듀얼 카와세 블러 구현에 앞서 그의 블룸 후처리 효과의 커스터마이징 과정을 다시 한번 살펴보고 싶었습니다.


 

원문

 

Froyok - Léna Piquet

Personal website of Froyok

www.froyok.fr

나는 사용자 정의 렌즈 플레어에 대한 내 글을 작성 한 이후 언리얼 엔진 블룸 효과를 수정할 수 있는지 확인해 보고 싶었습니다. 왜냐하면 기본 효과는 때로는 실망스러울 수 있고 항상 내가 기대하는 대로 동작하지 않을 때가 있기 때문입니다. 그래서 그 문제들을 해결하기 위해 다시 만들어 보려고 했습니다.

이 문서는 이전 문서(사용자 정의 렌즈플레어)를 따라 작성되었습니다. 사용자가 이미 사용자 지정 PostProcess 플러그인을 가지고 있고 코드와 셰이더를 위치시킬 곳을 알고 있다고 가정합니다.

이 수정은 언리얼 엔진 버전 4.25에서 테스트되었지만, 버전 4.26과 4.27에도 적용될 수 있습니다. 언리얼 엔진 5는 아직 이른 액세스 단계이기 때문에 확인하지 않았습니다.

 

블룸이란 무엇인가요?

처음에는 블룸이 무엇이며 왜 시뮬레이션하는지에 대한 요약을 쓰고 싶지 않았습니다. 당연한 것이라고 생각했기 때문입니다. 하지만 그렇지 않았고, 주제에 대해 약간의 지식을 모으는 것이 몇 가지를 더 잘 이해하게 해주었습니다.

우선, "블룸"은 실시간 렌더링에서는 흔한 용어이지만, 연구 및 광학에서는 주요 키워드로 사용되지 않습니다. 대신 "disability/discomfort glare" 또는 "veiling luminance"로 자주 언급됩니다. 저는 이 주제에 대한 흥미로운 출판물을 많이 찾을 수 있었고, 아래에 링크를 첨부했습니다.

 

Glare (vision) - Wikipedia

From Wikipedia, the free encyclopedia Bright light which impairs vision Glare from a camera flash during a Sumo fight Glare is difficulty of seeing in the presence of bright light such as direct or reflected sunlight or artificial light such as car headlam

en.wikipedia.org

 

 

Veiling glare - Wikipedia

From Wikipedia, the free encyclopedia Veiling glare in a photograph from Cassini (spacecraft) Veiling glare caused by stray light reflecting inside the camera or scattering in the lens Veiling glare is an imperfection of performance in optical instruments

en.wikipedia.org

몇 가지 과학 논문을 인용해보겠습니다:

"블룸은 종종 "가리는 밝기"라고 불리며, 밝은 물체 주위에 "빛나는 광택"을 일으킵니다. 블룸은 대조 감소를 일으켜 근처 물체의 가시성을 방해합니다 [...]."

"눈의 렌즈에서 빛을 산란 시키며 시야 주변에 있는 밝은 광원이 눈에 가려져 초점이 잡히지 않습니다. 어두운 환경에서 색 감도가 감소하며, 색 감도가 있는 원추 체계 대신 빛 감지 원봉이 작동하기 때문입니다."

"시각 운동능력인 공간 상세를 해결할 수 있는 능력도 어두운 환경에서 손상됩니다. 원추 반응의 완전한 손실과 빛 감지의 양자적 성질 때문입니다."

"시야 주변의 밝은 눈부심은 렌즈에서 산란된 빛이 중앙부 시력을 가리므로 대비 가시성이 감소합니다. 높은 조도에 직접적으로 시선을 맞출 때는 이 효과가 덜 두드러지게 됩니다."

또한 인간 눈의 눈부심 효과를 시뮬레이션하는 흥미로운 출판물도 있습니다):

 

이제 카메라 시점에서 보는 블룸의 모습은 다음과 같습니다:

 

(두 개의 LED 광원이 있는 구형 FinePix S1800으로 캡처했습니다.)

사진기의 동작은 인간의 눈과 유사하기 때문에, 블룸 역시 그들과 함께 관찰될 수 있습니다 (하지만 약간 다르게). 카메라 렌즈로 들어오는 빛은 정확성의 결여, 내부 유리의 결함 또는 그저 더러움 때문에 흩어집니다.
빛원이 덜 정의되어 더 흐릿한 이미지가 되며, 빛은 더 이상 하나의 점으로 집중되지 않기 때문에 눈부심 (때로는 유령 같은 현상)이 발생합니다. 에너지를 정보로 변환하는 부품인 센서도 과부하를 받을 수 있습니다. 이는 센서 그리드에서 받은 전기 충전이 이웃 셀로 누출되어 정보를 유출하는 것을 의미합니다.
컴퓨터 그래픽에서 블룸은 빛의 대기 중 희미한 산란 (예: 안개 속 가로등)을 매우 저렴하게 시뮬레이션하는 방법이 될 수도 있습니다.

 

다음은 몇 가지 추가 예시입니다:

 

(나의 책상 램프로 인한 렌즈 플레어와 눈부심.)

(휴대폰 손전등으로 인한 렌즈 플레어 및 눈부심)

 

(밝은 하늘을 흩뿌리는 짙은 안개.)


간단히 설명하자면 블룸은 빛이 센서(눈 또는 카메라)를 압도하여 매우 밝다는 것을 나타내는 시각 효과입니다. 컴퓨터로 생성한 이미지의 충실도와 자연스러움을 높이기 위해 블룸을 시뮬레이션하여 이 블룸을 재현합니다.

"My eyes !"

 

왜 블룸을 변경해야 할까요?

언리얼 엔진에는 표준 블룸과 컨벌루션 블룸 두 가지 종류가 있습니다.

(표준 vs 합성곱)

나는 컨볼루션을 기반으로 한 블룸에 대해서는 자세히 다루지 않을 것이다. 이는 실시간으로 사용하기에는 적합하지 않은 고급 후처리이다. UE5에서 미래에 더 저렴해질 수도 있지만, 현재로서는 실질적으로 사용할 수 없다.

대신, 나는 더 전통적인 효과에 집중했다. 소개에서 언급한 대로, UE4의 기본 블룸에는 항상 무언가가 걸렸다. 특히 최근의 다른 게임에서 만든 블룸과 비교한 후에는 더욱 그렇다.

다음은 기존 효과와 내 버전의 사이드 바이 사이드 비교이다:

(Old vs New)

이전 방법과 새로운 방법을 정확하게 비교하기 위해, 이전 블룸의 색조를 흰색으로 만들었습니다 (기본적으로 서로 다른 회색 수준을 가지고 있습니다). 이렇게 하면 실제 흐림 효과의 모양을 비교하고 이전 방법에서 발생하는 문제를 강조할 수 있습니다: 빛의 주변에 강한 빛나침과 그 뒤로 급격하게 사라지는 꼬리.

제대로 보정된 모니터가 없으면 차이를 알아차리기 어려울 수 있습니다. 그래서 확신을 얻기 위해 교차 섹션 비교를 수행하여 그라데이션의 모양을 더 쉽게 관찰했습니다:

많은 분들은 아시다시피, 이전의 블룸은 매끄러운 곡선을 가지지 않고, 각진 전환 부분이 보입니다.

이 모양이 의도적인 것으로 생각될 수 있지만 (나중에 더 자세히 설명하겠습니다), 저는 이 모양이 매우 불쾌하게 느껴집니다. 반면에, 새로운 방법은 결과물을 만들기 위해 다른 방식을 사용하여 훨씬 연속적인 그라디언트를 제공합니다.


전통적으로 블룸은 어떻게 구현되나요?

UE4 블룸을 이해하기 위해, 이 효과가 어떻게 만들어지고 최종 이미지에 혼합되는지 살펴봅시다. 많은 게임에서 다음과 같이 구현됩니다:

(가장 일반적인 과정 개요)

https://www.froyok.fr/blog/2021-12-ue4-custom-bloom/resources/papers/lighting_and_material_halo_3.pdf

 

Bloom

A Unity Advanced Rendering tutorial about creating a bloom effect.

catlikecoding.com

1. 장면의 색상을 읽고 밝은 픽셀만 추출하기 위해 임계값을 적용하세요.

2. 임계값의 결과를 흐리게 만들어서 보통은 GPU에서 빠르게 여러 단계로 수행합니다.

3.  씬 색상 위에 흐림 효과의 결과를 추가하십시오. 종종 톤매핑 과정 중에 수행됩니다.

그들은 매우 조잡한 단계들이지만, 여러 가지 질문들이 머릿속에 떠오릅니다: 왜 thresholding을 사용하는 걸까요? 왜 additive blending을 사용하는 걸까요? 왜 blurring을 하고, 어떻게 하나요?


임계값 처리

임계값 처리는 이미지의 매우 밝은 영역만을 선택하여 블룸에 기여하도록하는 것입니다. 이는 밝은 빛을 돋보이게 만드는 간단한 방법으로 예상치 못한 조명 (바닥이나 캐릭터의 머리가 어디에나 번지는 것과 같은)을 처리하지 않아도됩니다.

그러나 시각적 보정을 할 때 걱정할 것이 줄어들면 항상 유익합니다. 임계값이 없는 장점은 픽셀이 강도에 따라 제대로 사라지고 나타날 수 있기 때문에 더 자연스러운 반응하는 콘텐츠를 생성하는 데 도움이됩니다. 실제로는 임계값이 없으므로 조명과 시각적인 요소가 비슷한 방식으로 반응하는 것이 충실도를 향상시키는 데 도움이됩니다.

임계값이 없다는 것은 계산할 필요도 없다는 것을 의미하므로 성능을 약간 절약할 수 있습니다.

 

Gaussian Blur (가우시안 블러)

그렇다면 왜 블러가 필요할까요? 앞서 설명한 것처럼 빛이 산란되면 빛이 퍼지고 소스 이미지의 정확도/정확도가 떨어집니다.

그렇다면 왜 다른 분포에 기반한 블러가 아닌 가우시안 블러를 사용해야 할까요? 그것은 현실이 이미 그것에 가깝기 때문입니다:

"각막과 수정체에 대해서, 빛은 좁은 전방 원뿔에서 약간의 가우스 분포로 산란됩니다."

"각막 산란은 홍채가 망막에 그림자를 내리는 것과 구별될 수 있습니다. 망막 간 산란은 물리적으로 모든 방향으로 일어나지만, 원추 시스템의 방향 감도가 크게 감소되므로 동일한 전방 방향에서만 중요합니다 [...]."

"원봉 감도는 원추들보다 방향 감도가 높지 않기 때문에, 빛 반사의 크기는 어두운 (스코토픽) 조건에서 더 큽니다. 이러한 이유로 빛 산란은 약간의 "흐림" 또는 "번뜩임" 효과로 나타나며 급격한 감소가 있으며, 실험 결과와 일치시키기 위해 경험적인 공식으로 근사할 수 있습니다."

다른 관계 된 발행물들을 확인하지는 않았지만, 카메라는 빛 정보를 기록하는 센서가 렌즈 끝에 위치하기 때문에 비슷한 방식으로 작동할 것으로 추측됩니다. 아니면 Airy 디스크 때문에 Gaussian 분포로 근사화할 수 있는 것일지도 모릅니다.

하지만, 이러한 유형의 연구를 인용하는 게임/실시간 출판물을 찾지 못했기 때문에 컴퓨터 그래픽스에서 이미지 처리에 일반적으로 사용되어 왔기 때문에 Gaussian 블러를 선택한 것은 습관 때문인지 궁금합니다.

Gaussian 블러는 분리 가능하므로, 복잡한 단일 단계 대신 두 가지 간단한 단계로 계산할 수 있어 GPU 기반 구현에 매우 적합합니다.

 

가산 블렌딩

블룸이 생성되면 일반적으로 간단한 가산 색상으로 장면 색상에 추가됩니다. 일반적으로 이는 문제가 되지 않습니다. 왜냐하면 색상은 주로 HDR로 관리되고 톤매핑되기 때문에 추한 방식으로 클램핑되지 않기 때문입니다.

그러나 이러한 맥락에서 가산 블렌딩은 물리적으로 정확하지 않습니다. 블룸을 추가함으로써 밝은 픽셀을 더욱 더 밝게 만들고, 블러를 통해 다른 픽셀에 퍼지게 하여 이미지의 다른 영역도 밝게 만듭니다. 이는 장면의 내용을 왜곡시킬 수 있으며 어두운 분위기에서 균형을 잡기도 어렵습니다.

따라서 이미지의 원본에 블러를 블렌딩하면서 강도를 증가시키지 않는 대안적인 방법이 필요합니다. 선형 보간은 간단하고 좋은 대안입니다. 이 방법을 사용하면 임계값 이미지를 사용할 수 없으므로 원본 이미지의 강도를 잃을 수 있습니다.

 

언리얼 엔진 방법

UE4에서 표준 블룸은 2012년 Elemental 데모를 소개한 이후로 거의 변경되지 않았습니다. 아이디어는 넓은 가우시안 블러를 수행하면서 GPU의 작동 방식을 활용하여 렌더링 비용을 저렴하게 유지하는 것입니다:

프로세스는 이전 결과를 사용하여 장면 색상 크기를 여러 번 줄이는 (다운샘플링) 작업을 수행합니다. 크기를 줄일 때마다 여러 픽셀을 평균화하여 결과를 약간 흐리게 만듭니다. 이는 에일리어싱을 줄이고 시간적 안정성을 제공하기 위해 수행됩니다. 한 픽셀에 대해 4개의 픽셀이 샘플링되므로 (즉, 2x2 박스로), 샘플링 위치는 픽셀 사이에 있어 이중 선형 필터링을 활용합니다.

다음 단계는 가장 작은 텍스처로부터 가장 큰 텍스처로 돌아가는 것이며, 이를 위해 텍스처를 길게 만들면서 업샘플링을 수행합니다. 업샘플링 렌더링은 이전 텍스처와 현재 텍스처를 결합하여 분리 가능한 가우시안 블러로 흐리게 만듭니다 (이는 이미지가 한 축에서 흐려지고 결과가 다른 축을 따라 다시 흐려지는 것을 의미합니다).

또 다른 중요한 포인트는 다운샘플 수가 최대 확장 가능 수준에서 6으로 고정되어 있다는 것입니다. 이는 해상도를 증가시킬 때 시작 해상도 때문에 블러 크기가 줄어들게 됩니다. 따라서 1080p 대 4K에서 렌더링할 때 똑같이 보이지 않으며, 블러 크기로 수동으로 보상해야 하기 때문에 추가 다운샘플링보다 비용이 더 많이 들 수도 있습니다.

에픽 게임즈는 또한 DSLR로 촬영한 블룸의 실제 모양이 "매우 얇은 가시"임을 보여주었습니다. 그러나 그들은 이 정보를 작업하기에 애매모호하게 만드는 자세한 세부 사항 (카메라 모델 또는 측정 / 촬영 방법)을 제공하지 않았습니다.

밝은 면에서 (헤헤), 우리는 언리얼 엔진의 버전을 비교하여 블룸 후처리 설정이 어떻게 진화했는지 볼 수 있습니다.

Defaults values in version 4.7:

  • Bloom intensity: 1.0
  • Bloom threshold: 1.0
  • Bloom tint 1: Linear color of 0.5
  • Bloom tint 2: Linear color of 0.5
  • Bloom tint 3: Linear color of 0.5
  • Bloom tint 4: Linear color of 0.5
  • Bloom tint 5: Linear color of 0.5

Default values in version 4.8:

  • Bloom intensity: 0.675
  • Bloom threshold: -1.0
  • Bloom tint 1: Linear color of 0.3465
  • Bloom tint 2: Linear color of 0.138
  • Bloom tint 3: Linear color of 0.1176
  • Bloom tint 4: Linear color of 0.066
  • Bloom tint 5: Linear color of 0.066
  • Bloom tint 6: Linear color of 0.061

블룸 임계값이 제거되었습니다. (값이 -1로 설정되면 비활성화됩니다.) 또한, 블러 크기와 색조에 맞춰 밝기가 조정되었습니다. 각 색조는 블러 다운샘플 버퍼 중 하나에 해당합니다 (5단계에서 6단계로 변경되었습니다).

블룸 모양을 알아보기 위해 이를 그래프로 나타내 보았습니다. (Desmos로 제작한 그래프입니다: Desmos 링크)

 

Desmos | 그래핑 계산기

 

www.desmos.com

(UE4 4.8에서 블룸 틴트)

이것들만으로는 형태에 대한 정확한 아이디어를 얻기 어렵습니다. 그래서 일치하는 작업에 대해 다른 프로세스를 사용했습니다 (자세한 내용은 아래에 설명되어 있음).

 

콜 오브 듀티 메소드

콜 오브 듀티 어드밴스드 워페어 에서 개발자들은 UE4의 메소드에서 영감을 받아 사용했습니다:

아이디어는 여전히 다운샘플링 후 업샘플링 시리즈를 수행하는 것입니다. 그러나 2x2 박스 필터 대신, 안정성을 향상시키기 위해 다운샘플링에는 13개의 샘플 패턴을 사용하고, 업샘플링에는 9개의 샘플 패턴을 사용합니다. 언리얼과 마찬가지로 각 업샘플링 패스는 중간에 블러 처리된 버퍼를 사용합니다.


나의 방법

나의 방법은 Call of Duty에서 영감을 받았습니다. 따라서 동일한 다운샘플링/업샘플링 패턴을 사용하지만 중간의 블러 과정은 제거했습니다.

(내 방법의 간단한 개요.)

다운샘플링과 업샘플링

이 프로세스는 8단계로 구성되어 있습니다 (8번의 다운샘플링 다음에 7번의 업샘플링), 하지만 실제로는 엔진에서 이미 첫 번째 다운샘플링 (반 해상도의 씬 컬러)을 제공하므로 7번의 다운샘플링만 수행합니다.

8단계는 원래의 UE4 블룸 크기와 일치할 수 있는 매우 넓은 블러를 제공합니다. 중간 블러 패스를 건너뛰기 때문에 성능이 매우 좋으며, 작은 텍스처를 생성하는 것은 비용이 적게 듭니다.

이 단계 수는 화면 해상도에 맞춰야 합니다. 720p에서 8단계는 너무 많을 수 있으며, 4K에서는 충분하지 않을 수 있습니다. 그러나 단계 수를 조정하는 공식을 찾는 데 시간을 투자하고 싶지 않았습니다.

(프로세스 개요. 위의 썸네일은 더 좋은 시청을 위해 수동으로 톤매핑되었습니다.)

 

(프로세스의 작은 애니메이션.)

다운샘플링 프로세스는 이전 결과를 읽고 작은 해상도로 새로운 결과를 작성하기 때문에 간단합니다. 제공된 패턴을 사용하여 각 새로운 패스마다 이미지를 약간 확대합니다.

업샘플링은 약간 다릅니다. 이전 업샘플을 읽고 (샘플링 패턴을 사용하여 확대) 현재 다운샘플과 함께 바로 위의 해상도에서 결합합니다. 다음 패스는 해상도를 높이고 동일한 프로세스를 반복합니다.

업샘플을 결합하기 위해 패스는 선형 보간법으로 함께 혼합됩니다. 이렇게 함으로써 블렌드의 가중치를 변경하여 흐림의 범위를 제어할 수 있습니다.


임계값 지정과 블렌딩

이전에 설명한 대로, 저는 임계값 지정에 의존하지 않습니다. 어떤 경우에도 전체 화면이 흐리게 표시되며 최종적으로 번지 효과에 기여합니다. 번지 효과의 결과는 씬 색상과 톤맵 통과 내에서 선형 보간(lerp())을 사용하여 조합됩니다. 아래에서 일부 비교 결과를 확인하세요.

저는 이 아이디어를 Cody Darr (Sonic Ether)가 만든 번지 효과를 기반으로하여 흥미로운 결과를 보였습니다.


샘플링

쉐이더에서 텍스처를 읽을 때, 픽셀을 읽는 다양한 방법을 지정할 수 있습니다. 축소확대 필터와 감싸기 매개변수가 있습니다.

필터에는 하드웨어 리소스의 도움으로 한 번에 여러 픽셀을 샘플링할 수 있는 일반적인 이중 선형 필터링을 사용합니다. 감싸기에는 몇 가지 옵션을 선택할 수 있습니다:

  • 테두리로 고정: 텍스처 바깥 쪽의 색은 검정색입니다.
  • 가장자리로 고정: 텍스처 바깥 쪽의 색은 텍스처 가장자리의 마지막 픽셀과 동일합니다.
  • 반복: 텍스처가 감싸져 있으며, 텍스처 바깥을 읽을 때 반복됩니다.

다음은 다운샘플링 및 업샘플링 과정에서 각 모드를 사용할 때 발생하는 일입니다.

 

첫 번째 예제는 UE4의 블룸이 이미 동작하는 방식입니다. 화면 밖의 픽셀은 검정색이므로 샘플링 패턴 때문에 가장자리의 픽셀이 덜 밝아집니다. 이로 인해 모서리에서 눈에 띄는 페이딩이 발생합니다.

두 번째 예제는 이전 예제와 반대 효과로 인해 블룸이 지나치게 밝게 나타납니다. 가장자리의 픽셀이 반복되므로 샘플링 패턴과 밝기 증가로 인해 값이 잘못되어 버립니다. 따라서 가장자리에있는 밝은 픽셀은 화면 외부에 무한히 밝은 빛이 있는 것처럼 동작합니다.

세 번째 예제는 텍스처를 반복하는 것이 스크린 스페이스 효과에 작동하지 않음을 보여줍니다 (하지만 미러링 랩핑은 대안일 수 있습니다).

이 모든 예제에서 다운샘플링 및 업샘플링 프로세스 중 동일한 래핑을 사용했지만, 궁금해서 매개 변수를 약간 조정하여 더 좋아하는 결과를 얻었습니다.

 

(다시 한 번, 다운샘플과 업샘플 모두 테두리에 클램핑하여 비교합니다.)
(다운샘플의 경우 테두리에 클램프하고 업샘플의 경우 가장자리에 클램프합니다.)

이 하이브리드 모드는 밝은 영역이 사라질 때 가장자리에서 부드러운 페이드 효과를 제공하지만 이상한 모서리 페이드가 발생하지 않습니다.

다음은 또 다른 예시입니다:

  • 왼쪽: 경계에 대한 클램프는 다리 부근에서 가장자리가 흐려지게 만들면서도 캐릭터 머리 주변의 블이 둥글게 유지됩니다.
  • 오른쪽: 가장자리에 대한 클램프는 이상한 흐림이 사라지지만, 머리 모양을 완벽하게 따르지 않아 덜 둥글어 보일 수 있습니다.

두 가지 설정에는 장단점이 있지만, 개인적으로 대부분의 경우에 더 잘 작동하는 오른쪽의 결과를 선호합니다.


비교와 일치

이제 몇 개의 프로젝트에서 이전 효과와 새로운 효과 사이의 비교가 있습니다:

전설들에 대한 간단한 설명:

  • No bloom: 설명이 필요 없습니다. 기본 이미지를 보여주어 블룸을 추가하거나 혼합한 결과와 대조할 수 있습니다.
  • Old bloom: 기본 UE4 블룸입니다. PostProcess 볼륨 설정에서 강도를 1로, 임계값을 -1로 사용합니다. 블룸 틴트는 기본값입니다. 블룸 결과는 톤매핑 중에 장면 색상에 더해져 결합됩니다.
  • New bloom: 새로운 방법입니다. 각 예제는 다른 혼합 값의 결과를 보여줍니다. lerp blend은 톤매퍼 내에서 선형 보간 혼합을 의미합니다 (더하기 혼합 대신). internal blend은 업샘플 패스 사이에서 선형 보간 혼합을 의미합니다.

기본 블룸이 얼마나 집중되어 있고 강도가 빠르게 사라지는지 보는 것이 흥미롭습니다. 추가적인 블룸도 약간 이미지를 밝게 만듭니다.

반면, 초점이 맞춰진/작은 블룸을 사용하면 내가 만든 효과는 이미지를 밝게 만들지 않습니다. 반지름을 넓게 사용하면 낮은 빛은 빠르게 강도가 줄어들고 밝은 빛은 더 멀리 퍼질 것입니다.

내부 혼합값이 0.5인 경우 블룸은 상당히 중립적인 모양을 가지지만, 가우시안 블러와 더 잘 맞추기 위해 0.7과 0.85 사이의 값이 일반적으로 더 잘 작동합니다. 하지만 이렇게 하면 밝은 빛이 매우 넓어지므로 (예를 들어 강렬한 발광 표면을 덜 사용함) 컨텐츠를 조정해야 할 수도 있습니다.

참고로, 원래의 UE4 블룸에서도 틴트를 조정하여 광대한 테일을 가질 수 있습니다.


밝은 발광 구를 가진 간단한 장면을 만들고 스크린샷을 캡처한 다음 각 블룸의 테일을 비교하기 위해 크로스 섹션을 사용했습니다 (이전 vs 새로운). 이를 통해 내부 혼합값을 조정하여 원래의 블룸 테일과 일치시킬 수 있었습니다.

  • 빨강: 오래된 블룸. 강도 1과 기본 틴트, 가산적 톤매핑 블렌딩.
  • 초록: 새로운 블룸. 톤매퍼에서 0.1의 러프 블렌딩, 내부에서 0.8의 러프 블렌딩.

기본 방법과 내가 사용하는 방법(톤맵에 대한 0.3 블렌딩 및 내부에 대한 0.85 블렌딩 사용) 사이에 몇 가지 추가 비교가 있습니다.

더 강렬하고 넓은 피어남이 있더라도, 새로운 방법은 하이라이트를 제한하지 않고 더 부드럽고 쾌적한 결과를 보여줍니다.

 

단계 1: 엔진 렌더 프로세스 해킹

이전에도 한 번 나의 Lens-Flare 기사를 꼭 읽어보시기 바랍니다. 나의 설명은 그 기사를 기반으로 하고 있습니다.

이번에는 블룸과 렌즈 플레어 렌더링을 완전히 재정의하였습니다. 이전과는 달리 두 가지를 모두 재정의하는 것은 장점이 있습니다. 코드를 초기에 훨씬 더 가지치기 할 수 있기 때문에 수정해야 할 파일의 수를 줄일 수 있습니다.


Engine/Source/Runtime/Renderer/Private/PostProcess/PostProcessing.cpp를 열고 첫 번째 줄의 포함 바로 뒤에 이 새 델리게이트를 추가합니다:

[...]
#include "PixelShaderUtils.h"
#include "ScreenSpaceRayTracing.h"

DECLARE_MULTICAST_DELEGATE_FourParams( FPP_CustomBloomFlare, FRDGBuilder&, const FViewInfo&, const FScreenPassTexture&, FScreenPassTexture& );
RENDERER_API FPP_CustomBloomFlare PP_CustomBloomFlare;

이것은 플러그인을 엔진 프로세스에 연결할 수 있도록하는 대리자 함수입니다. 레이아웃은 다음과 같습니다:

  1. FPP_CustomBloomFlare: 이것은 대리자를 설정하고 이름을 지정하기 위한 것입니다.
  2. FRDGBuilder: Shader를 등록하고 렌더링하는 데 사용할 RenderGraph 참조입니다.
  3. FViewInfo: 현재 보기 설정 (카메라, 포스트 프로세싱 등)입니다.
  4. const FScreenPassTexture: 블룸을 생성하기 위한 입력으로 사용할 것입니다. 즉, 장면 색상입니다.
  5. FScreenPassTexture: 작업이 완료된 후 엔진에서 사용할 텍스처입니다.

이전 글에서는 새로운 RenderGraph 텍스처와 그 크기 속성을 주고받기 위해 사용자 정의 구조체를 정의했습니다. 그러나 글을 게시한 후에야 엔진에 이미 있는 FScreenPassTexture라는 구조체를 알게 되었고 어디에서든 사용할 수 있다는 것을 알게 되었습니다.

따라서 이제부터는 이 구조체를 사용하여 텍스처를 공유하고 반환하는데 사용하며, 이로 인해 코드가 상당히 간소화되었습니다.


그 다음 몇 줄 아래로 내려가서 네임스페이스에 도달하고 다음 cvar를 추가하세요:

namespace
{
//-----------------------------------// FroyokTAutoConsoleVariable<int32> CVarPostProcessingCustomBloomFlare(
    TEXT("r.PostProcessing.CustomBloomFlare"),
    1,
    TEXT("If enabled, use external Bloom/Lens-flares rendering"),
    ECVF_RenderThreadSafe);
//-----------------------------------

이 cvar은 이전 블룸과 새로운 블룸 사이를 쉽게 전환하기 위해 사용될 것입니다. 기본적으로 새로운 블룸을 사용하도록 설정되어있는 것에 주의하십시오.


아래로 스크롤하십시오. 다음과 같이 보이는 **if (bBloomEnabled)**로 시작하는 if 블록에 도달할 때까지 스크롤하십시오:

if (bBloomEnabled)
        {
            FSceneDownsampleChain BloomDownsampleChain;

            FBloomInputs PassInputs;
            PassInputs.SceneColor = SceneColor;

            const bool bBloomThresholdEnabled = View.FinalPostProcessSettings.BloomThreshold > -1.0f;

// Reuse the main scene downsample chain if a threshold isn't required for bloom.if (SceneDownsampleChain.IsInitialized() && !bBloomThresholdEnabled)
            {
                [...]
            }
            else
            {
                [...]
            }

            FBloomOutputs PassOutputs = AddBloomPass(GraphBuilder, View, PassInputs);
            SceneColor = PassOutputs.SceneColor;
            Bloom = PassOutputs.Bloom;

            FScreenPassTexture LensFlares = AddLensFlaresPass(GraphBuilder, View, Bloom, *PassInputs.SceneDownsampleChain);

            if (LensFlares.IsValid())
            {
// Lens flares are composited with bloom.Bloom = LensFlares;
            }
        }

다른 if() 블록 안에 코드를 포함하세요. 이것이 우리만의 프로세스를 연결하는 방법이 될 것입니다:

if (bBloomEnabled)
        {
// Froyokif( !CVarPostProcessingCustomBloomFlare.GetValueOnRenderThread() )
            {
                FSceneDownsampleChain BloomDownsampleChain;

                FBloomInputs PassInputs;
                PassInputs.SceneColor = SceneColor;

                const bool bBloomThresholdEnabled = View.FinalPostProcessSettings.BloomThreshold > -1.0f;

// Reuse the main scene downsample chain if a threshold isn't required for bloom.if (SceneDownsampleChain.IsInitialized() && !bBloomThresholdEnabled)
                {
                    [...]
                }
                else
                {
                    [...]
                }

                FBloomOutputs PassOutputs = AddBloomPass(GraphBuilder, View, PassInputs);
                SceneColor = PassOutputs.SceneColor;
                Bloom = PassOutputs.Bloom;

                FScreenPassTexture LensFlares = AddLensFlaresPass(
                    GraphBuilder,
                    View,
                    Bloom,
                    *PassInputs.SceneDownsampleChain
                );

                if (LensFlares.IsValid())
                {
// Lens flares are composited with bloom.Bloom = LensFlares;
                }
            }
            else
            {
// Call the delegate functions// This will run the rendering code from our PluginPP_CustomBloomFlare.Broadcast( GraphBuilder, View, HalfResolutionSceneColor, Bloom );
            }// End CVarPostProcessingCustomBloomFlare}

위에서 언급한 대로, 이것은 bloom렌즈 플레어 코드를 모두 덮어씁니다. 이는 저의 블룸 프로세스를 저의 사용자 정의 렌즈 플레어와 결합시켰기 때문입니다. 원래 코드의 일부를 조정해야 하기 때문에 블룸만 덮어쓰는 방법에 대해 다루지 않았습니다.


단계 2: 엔진 톤매퍼 편집

다음 단계는 톤매퍼 내에서 블룸이 혼합되는 방식을 변경하는 것입니다. 따라서 파일 Engine/Shaders/Private/PostProcessTonemap.usf 을 열어 합성을 조정하세요. 먼저 파일의 시작 부분에 새로운 매개변수를 추가하세요:

다음 코드를 찾으세요:

            #if FEATURE_LEVEL == FEATURE_LEVEL_ES3_1
// Support sunshaft and vignette for mobile, and we have applied the BloomIntensity and the BloomDirtMask at the sun merge pass.LinearColor = LinearColor.rgb * CombinedBloom.a + CombinedBloom.rgb;
            #else
                float2 DirtLensUV = ConvertScreenViewportSpaceToLensViewportSpace(ScreenPos) * float2(1.0f, -1.0f);

                float3 BloomDirtMaskColor = Texture2DSample(BloomDirtMaskTexture, BloomDirtMaskSampler, DirtLensUV * .5f + .5f).rgb * BloomDirtMaskTint.rgb;

                LinearColor += CombinedBloom.rgb * (ColorScale1.rgb + BloomDirtMaskColor);
            #endif
        #endif

그리고 변경합니다:

#if FEATURE_LEVEL == FEATURE_LEVEL_ES3_1
// Support sunshaft and vignette for mobile, and we have applied the BloomIntensity and the BloomDirtMask at the sun merge pass.LinearColor = LinearColor.rgb * CombinedBloom.a + CombinedBloom.rgb;
            #else
                float2 DirtLensUV = ConvertScreenViewportSpaceToLensViewportSpace(ScreenPos) * float2(1.0f, -1.0f);

                float3 BloomDirtMaskColor = Texture2DSample(BloomDirtMaskTexture, BloomDirtMaskSampler, DirtLensUV * .5f + .5f).rgb * BloomDirtMaskTint.rgb;

//-------------------------------// Froyok//-------------------------------float3 BloomMask = saturate( ColorScale1.rgb + BloomDirtMaskColor );
                LinearColor = lerp( LinearColor, CombinedBloom, BloomMask );
            #endif
        #endif

이제 보시는 것처럼, 블렌딩이 간단한 덧셈에서 선형 보간으로 변경되었습니다. 블렌딩의 양은 실제로 후처리 볼륨의 블룸 강도인 ColorScale1 변수로 제어됩니다 (이는 PostProcessTonemap.cpp에서 정의되어 있습니다).


Step 3: 엔진 서브시스템

이제 플러그인 자체로 넘어가 보겠습니다.


PostProcessSubsystem.h

#pragma once

#include "CoreMinimal.h"
#include "PostProcess/PostProcessLensFlares.h" // For PostProcess delegate
#include "PostProcessSubsystem.generated.h"

DECLARE_MULTICAST_DELEGATE_FourParams( FPP_CustomBloomFlare, FRDGBuilder&, const FViewInfo&, const FScreenPassTexture&, FScreenPassTexture& );
extern RENDERER_API FPP_CustomBloomFlare PP_CustomBloomFlare;

UCLASS()
class CUSTOMPOSTPROCESS_API UPostProcessSubsystem : public UEngineSubsystem
{
    GENERATED_BODY()

    public:
        virtual void Initialize(FSubsystemCollectionBase& Collection) override;

        virtual void Deinitialize() override;

    private:
//------------------------------------// Helpers//------------------------------------// Internal blending and sampling states;FRHIBlendState* ClearBlendState = nullptr;
        FRHIBlendState* AdditiveBlendState = nullptr;

        FRHISamplerState* BilinearClampSampler = nullptr;
        FRHISamplerState* BilinearBorderSampler = nullptr;
        FRHISamplerState* BilinearRepeatSampler = nullptr;
        FRHISamplerState* NearestRepeatSampler = nullptr;

        void InitStates();

//------------------------------------// Main function//------------------------------------void Render(
            FRDGBuilder& GraphBuilder,
            const FViewInfo& View,
            const FScreenPassTexture& SceneColor,
            FScreenPassTexture& Output
        );

        TArray< FScreenPassTexture > MipMapsDownsample;
        TArray< FScreenPassTexture > MipMapsUpsample;


//------------------------------------// Bloom//------------------------------------FScreenPassTexture RenderBloom(
            FRDGBuilder& GraphBuilder,
            const FViewInfo& View,
            const FScreenPassTexture& SceneColor,
            int32 PassAmount
        );

        FRDGTextureRef RenderDownsample(
            FRDGBuilder& GraphBuilder,
            const FString& PassName,
            const FViewInfo& View,
            FRDGTextureRef InputTexture,
            const FIntRect& Viewport
        );

        FRDGTextureRef RenderUpsampleCombine(
            FRDGBuilder& GraphBuilder,
            const FString& PassName,
            const FViewInfo& View,
            const FScreenPassTexture& InputTexture,
            const FScreenPassTexture& PreviousTexture,
            float Radius
        );
};

제가 이전 기사와 비교하여 블룸을 수행하기 위해 만든 함수 중 일부를 꽤 많이 변경했기 때문에 하위 시스템 헤더를 다시 게시하고 있습니다.

일부 참고 사항:

  • 렌즈 플레어 기능들은 여기서 다시 다루지 않기로 결정해서 누락되었습니다. 그들은 크게 변경되지 않았습니다 (그들의 선언은 블룸과 더 비슷하게 되었습니다).
  • 셰이더를 위한 블렌딩 상태는 렌더링 함수들 간에 쉽게 재사용하기 위해 저장되어 있습니다. InitStates() 함수는 렌더링하기 전에 블렌딩 상태가 유효한지 확인하는 작은 도우미 함수입니다.
  • Render() 함수는 후크를 통해 등록되는 주요 렌더링 함수입니다. 여기서 블룸이 호출되고, (있는 경우) 렌즈 플레어와 혼합됩니다.
  • RenderBloom() 함수는 블룸을 생성하는 주요 함수입니다. 생성할 패스의 수와 몇 가지 기타 세부 사항을 처리합니다.
  • RenderDownsample()RenderUpsampleCombine() 함수는 가우시안 블러 레벨을 생성하는 함수입니다 (개요에서 언급한 것처럼).

서브시스템 본문 상단에는 아래의 cvars가 있습니다. 이를 통해 블룸을 조절할 수 있습니다:

PostProcessSubsystem.cpp

#include "PostProcessSubsystem.h"
#include "PostProcessLensFlareAsset.h"
#include "Interfaces/IPluginManager.h"

#include "RenderGraph.h"
#include "ScreenPass.h"


TAutoConsoleVariable<int32> CVarBloomPassAmount(
    TEXT("r.Froyok.BloomPassAmount"),
    8,
    TEXT(" Number of passes to render bloom"),
    ECVF_RenderThreadSafe);

TAutoConsoleVariable<float> CVarBloomRadius(
    TEXT("r.Froyok.BloomRadius"),
    0.85,
    TEXT(" Size/Scale of the Bloom"),
    ECVF_RenderThreadSafe);

다음은 cvars가 하는 일입니다:

  • BloomPassAmount: 이 변수는 블러를 생성하기 위해 수행할 다운스케일과 업스케일의 양을 정의합니다.
  • BloomRadius: 이 변수는 업샘플을 수행할 때 이전 패스와 현재 패스 사이의 블렌딩 가중치를 정의합니다. 몇 번 언급한 것처럼, 이것은 "내부 블렌드" 값입니다.

이제 우리는 서브시스템의 Initialize() 함수를 설정할 수 있습니다:

void UPostProcessSubsystem::Initialize( FSubsystemCollectionBase& Collection )
{
    Super::Initialize( Collection );

//--------------------------------// Setup delegate//--------------------------------FPP_CustomBloomFlare::FDelegate Delegate = FPP_CustomBloomFlare::FDelegate::CreateLambda(
        [=]( FRDGBuilder& GraphBuilder, const FViewInfo& View, const FScreenPassTexture& SceneColor, FScreenPassTexture& Output )
    {
        Render( GraphBuilder, View, SceneColor, Output );
    });

    ENQUEUE_RENDER_COMMAND(BindRenderThreadDelegates)( [Delegate](FRHICommandListImmediate& RHICmdList )
    {
        PP_CustomBloomFlare.Add(Delegate);
    });

// Additional stuff if you have the lens-flares...[...]
}

이전에 언급한 대로, **Render()**는 콜백에 연결하는 주요 렌더링 함수입니다 (여기에서는 람다의 도움을 받아 연결합니다).


다음은 작은 정리 및 상태 설정 함수입니다:

void UPostProcessSubsystem::Deinitialize()
{
    ClearBlendState = nullptr;
    AdditiveBlendState = nullptr;
    BilinearClampSampler = nullptr;
    BilinearBorderSampler = nullptr;
    BilinearRepeatSampler = nullptr;
    NearestRepeatSampler = nullptr;
}


void UPostProcessSubsystem::InitStates()
{
    if( ClearBlendState != nullptr )
    {
        return;
    }

// Blend modes from:// '/Engine/Source/Runtime/RenderCore/Private/ClearQuad.cpp'// '/Engine/Source/Runtime/Renderer/Private/PostProcess/PostProcessMaterial.cpp'ClearBlendState = TStaticBlendState<>::GetRHI();
    AdditiveBlendState = TStaticBlendState<CW_RGB, BO_Add, BF_One, BF_One>::GetRHI();

    BilinearClampSampler = TStaticSamplerState<SF_Bilinear, AM_Clamp, AM_Clamp, AM_Clamp>::GetRHI();
    BilinearBorderSampler = TStaticSamplerState<SF_Bilinear, AM_Border, AM_Border, AM_Border>::GetRHI();
    BilinearRepeatSampler = TStaticSamplerState<SF_Bilinear, AM_Wrap, AM_Wrap, AM_Wrap>::GetRHI();
    NearestRepeatSampler = TStaticSamplerState<SF_Point, AM_Wrap, AM_Wrap, AM_Wrap>::GetRHI();
}

 

단계 4: 글로벌 셰이더

PostProcessSubsystem.cpp 내부에 이미 일부 글로벌 셰이더를 정의하는 네임스페이스가 있어야 합니다. 여기에 우리는 블룸 셰이더를 추가할 것입니다.

Downsample Shader

PostProcessSubsystem.cpp (in namespace)

// Bloom downsampleclass FDownsamplePS : public FGlobalShader
    {
        public:
            DECLARE_GLOBAL_SHADER(FDownsamplePS);
            SHADER_USE_PARAMETER_STRUCT(FDownsamplePS, FGlobalShader);

            BEGIN_SHADER_PARAMETER_STRUCT(FParameters, )
                SHADER_PARAMETER_STRUCT_INCLUDE(FCustomLensFlarePassParameters, Pass)
                SHADER_PARAMETER_SAMPLER(SamplerState, InputSampler)
                SHADER_PARAMETER(FVector2D, InputSize)
            END_SHADER_PARAMETER_STRUCT()

            static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters)
            {
                return IsFeatureLevelSupported(Parameters.Platform, ERHIFeatureLevel::SM5);
            }
    };
    IMPLEMENT_GLOBAL_SHADER(FDownsamplePS, "/CustomShaders/DownsampleThreshold.usf", "DownsamplePS", SF_Pixel);

DownsampleThreshold.usf

#include "Shared.ush"

float2 InputSize;

float3 Downsample( Texture2D Texture, SamplerState Sampler, float2 UV, float2 PixelSize )
{
    const float2 Coords[13] = {
        float2( -1.0f,  1.0f ), float2(  1.0f,  1.0f ),
        float2( -1.0f, -1.0f ), float2(  1.0f, -1.0f ),

        float2(-2.0f, 2.0f), float2( 0.0f, 2.0f), float2( 2.0f, 2.0f),
        float2(-2.0f, 0.0f), float2( 0.0f, 0.0f), float2( 2.0f, 0.0f),
        float2(-2.0f,-2.0f), float2( 0.0f,-2.0f), float2( 2.0f,-2.0f)
    };


    const float Weights[13] = {
// 4 samples// (1 / 4) * 0.5f = 0.125f0.125f, 0.125f,
        0.125f, 0.125f,

// 9 samples// (1 / 9) * 0.5f0.0555555f, 0.0555555f, 0.0555555f,
        0.0555555f, 0.0555555f, 0.0555555f,
        0.0555555f, 0.0555555f, 0.0555555f
    };

    float3 OutColor = float3( 0.0f, 0.0f ,0.0f );

    UNROLL
    for( int i = 0; i < 13; i++ )
    {
        float2 CurrentUV = UV + Coords[i] * PixelSize;
        OutColor += Weights[i] * Texture2DSample(Texture, Sampler, CurrentUV ).rgb;
    }

    return OutColor;
}

void DownsamplePS(
    in noperspective float4 UVAndScreenPos : TEXCOORD0,
    out float3 OutColor : SV_Target0 )
{
    float2 InPixelSize = (1.0f / InputSize) * 0.5;
    float2 UV = UVAndScreenPos.xy;
    OutColor.rgb = Downsample( InputTexture, InputSampler, UV, InPixelSize );
}

다운샘플 셰이더는 매우 간단합니다. 이전 텍스처를 입력으로 받아 이전 텍스처를 렌더링하여 텍스처로 반환합니다. 이 함수는 시작할 때 본 패턴과 동일하게 위치한 13개의 샘플을 반환합니다.


Upsample Shader

PostProcessSubsystem.cpp (in namespace)

// Bloom upsample + combineclass FUpsampleCombinePS : public FGlobalShader
    {
        public:
            DECLARE_GLOBAL_SHADER(FUpsampleCombinePS);
            SHADER_USE_PARAMETER_STRUCT(FUpsampleCombinePS, FGlobalShader);

            BEGIN_SHADER_PARAMETER_STRUCT(FParameters, )
                SHADER_PARAMETER_STRUCT_INCLUDE(FCustomLensFlarePassParameters, Pass)
                SHADER_PARAMETER_SAMPLER(SamplerState, InputSampler)
                SHADER_PARAMETER(FVector2D, InputSize)
                SHADER_PARAMETER_RDG_TEXTURE(Texture2D, PreviousTexture)
                SHADER_PARAMETER(float, Radius)
            END_SHADER_PARAMETER_STRUCT()

            static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters)
            {
                return IsFeatureLevelSupported(Parameters.Platform, ERHIFeatureLevel::SM5);
            }
    };
    IMPLEMENT_GLOBAL_SHADER(FUpsampleCombinePS, "/CustomShaders/DownsampleThreshold.usf", "UpsampleCombinePS", SF_Pixel);

 

DownsampleThreshold.usf

Texture2D PreviousTexture;
float Radius;

float3 Upsample( Texture2D Texture, SamplerState Sampler, float2 UV, float2 PixelSize )
{
    const float2 Coords[9] = {
        float2( -1.0f,  1.0f ), float2(  0.0f,  1.0f ), float2(  1.0f,  1.0f ),
        float2( -1.0f,  0.0f ), float2(  0.0f,  0.0f ), float2(  1.0f,  0.0f ),
        float2( -1.0f, -1.0f ), float2(  0.0f, -1.0f ), float2(  1.0f, -1.0f )
    };

    const float Weights[9] = {
        0.0625f, 0.125f, 0.0625f,
        0.125f,  0.25f,  0.125f,
        0.0625f, 0.125f, 0.0625f
    };

    float3 Color = float3( 0.0f, 0.0f, 0.0f );

    UNROLL
    for( int i = 0; i < 9; i++ )
    {
        float2 CurrentUV = UV + Coords[i] * PixelSize;
        Color += Weights[i] * Texture2DSampleLevel(Texture, Sampler, CurrentUV, 0).rgb;
    }

    return Color;
}

void UpsampleCombinePS(
    in noperspective float4 UVAndScreenPos : TEXCOORD0,
    out float3 OutColor : SV_Target0 )
{
    float2 InPixelSize = 1.0f / InputSize;
    float2 UV = UVAndScreenPos.xy;

    float3 CurrentColor = Texture2DSampleLevel( InputTexture, InputSampler, UV, 0).rgb;
    float3 PreviousColor = Upsample( PreviousTexture, InputSampler, UV, InPixelSize );

    OutColor.rgb = lerp(CurrentColor, PreviousColor, Radius);
}

같은 원리입니다. 이전에 본 것과 같이 9개의 샘플 패턴을 복제하는 함수가 여기에 있습니다.

가장 중요한 세부 사항은 아마도 lerp() 함수입니다. 이 함수는 현재 색상과 이전 색상 사이를 Radius 변수로 제어합니다. 이 부분에서 진짜 가우시안 블러를 원한다면 이 lerp를 간단한 덧셈으로 대체할 수 있습니다.

하지만 이렇게 하면 최종 블룸 렌더링을 정규화하기 위해 수행한 패스 수로 나누어야 합니다. 나는 추가 매개변수를 전달해야 하는 다운 패스와 업 패스를 복잡하게 만들지 않기 위해 최종 혼합 패스에서 이 작업을 수행하도록 했습니다.

또한 각 업샘플 패스에서 결과를 직접 정규화하는 것이 (x + y) * 0.5와 같은 전통적인 방식으로는 제대로 작동하지 않습니다. 이는 각 패스에서 이전 결과를 누적하기 때문에 (lerp와 동일한 동작을 하게 됩니다). 그래서 처음에는 정규화를 나중에 수행한 후 명시적인 선형 보간으로 전환했습니다.

DownsampleThreshold.usf 파일 내에서는 더 이상 임계값에 대한 언급이 없습니다. 이는 블룸에서와 마찬가지로 렌즈 플레어에 대해 어떠한 임계값도 수행하지 않고 대신 블룸 다운스케일의 결과를 재사용하기 때문입니다.

이전 글에서 사용한 파일을 그대로 가져오고 내용을 업데이트했습니다. 파일 이름을 변경해도 상관없지만 위의 셰이더 정의를 조정해야 합니다.


Step 5: the Render Function

이제 메인 렌더링 기능으로 넘어가 보겠습니다:

PostProcessSubsystem.cpp

void UPostProcessSubsystem::Render(
    FRDGBuilder& GraphBuilder,
    const FViewInfo& View,
    const FScreenPassTexture& SceneColor,
    FScreenPassTexture& Output
)
{
    check( SceneColor.IsValid() );

    if( PostProcessAsset == nullptr )
    {
        return;
    }

    InitStates();

    RDG_GPU_STAT_SCOPE(GraphBuilder, PostProcessFroyok)
    RDG_EVENT_SCOPE(GraphBuilder, "PostProcessFroyok");

    int32 PassAmount = CVarBloomPassAmount.GetValueOnRenderThread();
[...]

보시다시피, 이곳에서는 렌더 함수 전체에서 사용될 다양한 블렌딩 및 샘플링 상태를 설정하는 InitState() 함수를 호출합니다. 그리고 그래픽 디버거를 사용하거나 프로파일링을 하려는 경우를 위해 몇 가지 통계를 정의하는 기회도 있습니다 (이 주제에 대한 이전 글에서 자세한 내용을 확인할 수 있습니다).

마지막으로 블룸 패스의 수를 가져와서 여기에 저장합니다. 이 작업을 여기에서 수행하는 주된 이유는 실제 블룸 렌더링 함수와 혼합 함수 사이에서 결과를 정규화해야 할 때 (다시 한 번 언급하자면 이제는 하지 않습니다) 편리하게 공유하기 위함입니다.

[...]

// Buffers setupconst FScreenPassTexture BlackDummy{
        GraphBuilder.RegisterExternalTexture(
            GSystemTextures.BlackDummy,
            TEXT("BlackDummy")
        )
    };

    FScreenPassTexture BloomTexture;
    FScreenPassTexture FlareTexture;
    FScreenPassTexture GlareTexture;
    FScreenPassTexture InputTexture( SceneColor.Texture );

// Scene color setup// We need to pass a FScreenPassTexture into FScreenPassTextureViewport()// and not a FRDGTextureRef (aka SceneColor.Texture) to ensure we can compute// the right Rect vs Extent sub-region. Otherwise only the full buffer// resolution is gonna be reported leading to NaNs/garbage in the rendering.const FScreenPassTextureViewport SceneColorViewport( SceneColor );
    const FVector2D SceneColorViewportSize = GetInputViewportSize( SceneColorViewport.Rect, SceneColorViewport.Extent );

[...]

지금 다양한 버퍼들을 계속해 보겠습니다:

  • BlackDummy: 이것은 간단한 비어있는 RDG 텍스처입니다. 대부분 디버깅할 때 렌더 패스를 비활성화할 때 유용합니다. 왜냐하면 항상 유효한 텍스처를 셰이더에 연결해야 하기 때문입니다. 그래서 검은색 텍스처는 언제나 사용할 수 있지만 아무런 기여를 하지 않습니다.
  • BloomTexture (그리고 다른 FScreenPassTexture): 이들은 각각의 렌더 패스가 출력할 최종 버퍼들입니다. 저는 블룸에 대해서만 다룰 것이지만, 이 새로운 구조체로 이전의 기사에서 작성한 코드를 쉽게 적용할 수 있을 것입니다.
  • Scene color setup: 주석에서 중요한 세부 사항을 대부분 다룹니다. 이는 주로 리스케일 패스를 올바르게 수행하기 위해 사용됩니다.
[...]
////////////////////////////////////////////////////////////////////////// Editor buffer rescale////////////////////////////////////////////////////////////////////////#if WITH_EDITOR
// Rescale the Scene Color to fit the whole texture and not use a sub-region.// This is to simplify the render pass (shaders) that come after.// This part is skipped when built without the editor because// it is not needed (unless splitscreen needs it ?).if( SceneColorViewport.Rect.Width()  != SceneColorViewport.Extent.X
    ||  SceneColorViewport.Rect.Height() != SceneColorViewport.Extent.Y )
    {
        const FString PassName("SceneColorRescale");

// Build textureFRDGTextureDesc Desc = SceneColor.Texture->Desc;
        Desc.Reset();
        Desc.Extent     = SceneColorViewport.Rect.Size();
        Desc.Format     = PF_FloatRGB;
        Desc.ClearValue = FClearValueBinding(FLinearColor::Transparent);
        FRDGTextureRef RescaleTexture = GraphBuilder.CreateTexture(Desc, *PassName);

// Render shaderTShaderMapRef<FCustomScreenPassVS> VertexShader(View.ShaderMap);
        TShaderMapRef<FRescalePS> PixelShader(View.ShaderMap);

        FRescalePS::FParameters* PassParameters = GraphBuilder.AllocParameters<FRescalePS::FParameters>();
        PassParameters->Pass.InputTexture       = SceneColor.Texture;
        PassParameters->Pass.RenderTargets[0]   = FRenderTargetBinding(RescaleTexture, ERenderTargetLoadAction::ENoAction);
        PassParameters->InputSampler            = BilinearClampSampler;
        PassParameters->InputViewportSize       = SceneColorViewportSize;

        DrawShaderPass(
            GraphBuilder,
            PassName,
            PassParameters,
            VertexShader,
            PixelShader,
            ClearBlendState,
            SceneColorViewport.Rect
        );

        InputTexture.Texture = RescaleTexture;
        InputTexture.ViewRect = SceneColorViewport.Rect;
    }
    #endif
[...]

이전과 동작이 거의 동일한 코드이며, 단지 몇 가지 변수 이름만 FScreenPassTexture 구조체의 사용에 맞게 조정되었습니다.

[...]
////////////////////////////////////////////////////////////////////////// Render passes////////////////////////////////////////////////////////////////////////// Bloom{
        BloomTexture = RenderBloom(
            GraphBuilder,
            View,
            InputTexture,
            PassAmount
        );
    }

// FlareTexture = RenderFlare( GraphBuilder, View );// GlareTexture = RenderGlare( GraphBuilder, View );[...]

이제 우리는 bloom 함수를 호출하여 렌더링합니다.

Flare 및 Glare 렌더링 및 변경 내용에 대해서는 다루지 않겠습니다. 이전과 마찬가지로 FScreenPassTexture를 반환하도록 변경되었지만 변수를 더 이상 전달할 필요가 없습니다. 이제 많은 변수들이 전체 하위 시스템에서 공유되기 때문입니다.

[...]
////////////////////////////////////////////////////////////////////////// Composite Bloom, Flare and Glare together////////////////////////////////////////////////////////////////////////FRDGTextureRef MixTexture = nullptr;
    FIntRect MixViewport{
        0,
        0,
        View.ViewRect.Width() / 2,
        View.ViewRect.Height() / 2
    };

    {
        RDG_EVENT_SCOPE(GraphBuilder, "MixPass");

        const FString PassName("Mix");

        float BloomIntensity = 1.0f;

// If the internal blending for the upsample pass is additive// (aka not using the lerp) then uncomment this line to// normalize the final bloom intensity.//  BloomIntensity = 1.0f / float( FMath::Max( PassAmount, 1 ) );FVector2D BufferSize{
            float( MixViewport.Width() ),
            float( MixViewport.Height() )
        };

        FIntVector BuffersValidity{
            ( BloomTexture.IsValid() ),
            ( FlareTexture.IsValid() ),
            ( GlareTexture.IsValid() )
        };

// Create textureFRDGTextureDesc Description = SceneColor.Texture->Desc;
        Description.Reset();
        Description.Extent = MixViewport.Size();
        Description.Format = PF_FloatRGB;
        Description.ClearValue = FClearValueBinding(FLinearColor::Black);
        MixTexture = GraphBuilder.CreateTexture(Description, *PassName);

// Render shaderTShaderMapRef<FCustomScreenPassVS> VertexShader(View.ShaderMap);
        TShaderMapRef<FMixPS> PixelShader(View.ShaderMap);

        FMixPS::FParameters* PassParameters = GraphBuilder.AllocParameters<FMixPS::FParameters>();
        PassParameters->Pass.RenderTargets[0]   = FRenderTargetBinding(MixTexture, ERenderTargetLoadAction::ENoAction);
        PassParameters->InputSampler            = BilinearClampSampler;
        PassParameters->MixPass                 = BuffersValidity;
[...]

MixTextureMixViewport는 후처리의 최종 결과입니다. 따라서 믹싱 패스를 렌더링하는 데 사용되는 범위 외부에서 선언하여 마지막에 액세스할 수 있도록 합니다.

[...]
// BloomPassParameters->BloomTexture            = BlackDummy.Texture;
        PassParameters->BloomIntensity          = BloomIntensity;

// GlarePassParameters->GlareTexture            = BlackDummy.Texture;
        PassParameters->GlarePixelSize          = FVector2D( 1.0f, 1.0f ) / BufferSize;

// FlarePassParameters->Pass.InputTexture       = BlackDummy.Texture;
        PassParameters->FlareIntensity          = PostProcessAsset->FlareIntensity;
        PassParameters->FlareTint               = FVector4( PostProcessAsset->FlareTint );
        PassParameters->FlareGradientTexture    = GWhiteTexture->TextureRHI;
        PassParameters->FlareGradientSampler    = BilinearClampSampler;

        if( PostProcessAsset->FlareGradient != nullptr )
        {
            const FTextureRHIRef TextureRHI = PostProcessAsset->FlareGradient->Resource->TextureRHI;
            PassParameters->FlareGradientTexture = TextureRHI;
        }

        if( BuffersValidity.X )
        {
            PassParameters->BloomTexture = BloomTexture.Texture;
        }

        if( BuffersValidity.Y )
        {
            PassParameters->Pass.InputTexture = FlareTexture.Texture;
        }

        if( BuffersValidity.Z )
        {
            PassParameters->GlareTexture = GlareTexture.Texture;
        }

// RenderDrawShaderPass(
            GraphBuilder,
            PassName,
            PassParameters,
            VertexShader,
            PixelShader,
            ClearBlendState,
            MixViewport
        );

// Reset texture listsMipMapsDownsample.Empty();
        MipMapsUpsample.Empty();

    }// end of mixing scope// OutputOutput.Texture = MixTexture;
    Output.ViewRect = MixViewport;

}// end of Render()

이제 BlackDummy 텍스처가 사용 가능하므로 기본값으로 설정하고, 주어진 버퍼가 유효한 경우에만 재정의할 수 있습니다. 이전 버전과 비교하여 코드가 상당히 간소화됩니다.


저는 몇 가지 조정을 한 후에 내 믹싱 셰이더의 내용을 다시 게시하고 있습니다(특히 새로운 블룸에 대해서).

Mix.usf

#include "Shared.ush"

// Commonint3 MixPass;

// BloomTexture2D BloomTexture;
float BloomIntensity;

// GlareTexture2D GlareTexture;
float2 GlarePixelSize;

// Flarefloat FlareIntensity;
float4 FlareTint;
Texture2D FlareGradientTexture;
SamplerState FlareGradientSampler;


void MixPS(
    in noperspective float4 UVAndScreenPos : TEXCOORD0,
    out float4 OutColor : SV_Target0 )
{
    float2 UV = UVAndScreenPos.xy;
    OutColor.rgb = float3( 0.0f, 0.0f, 0.0f );
    OutColor.a = 0;

//---------------------------------------// Add Bloom//---------------------------------------if( MixPass.x )
    {
        OutColor.rgb += Texture2DSample( BloomTexture, InputSampler, UV ).rgb * BloomIntensity;
    }

//---------------------------------------// Add Flares, Glares mixed with Tint/Gradient//---------------------------------------float3 Flares = float3( 0.0f, 0.0f, 0.0f );

// Flaresif( MixPass.y )
    {
        Flares = Texture2DSample( InputTexture, InputSampler, UV ).rgb;
    }

// Glaresif( MixPass.z )
    {
        const float2 Coords[4] = {
            float2(-1.0f, 1.0f),
            float2( 1.0f, 1.0f),
            float2(-1.0f,-1.0f),
            float2( 1.0f,-1.0f)
        };

        float3 GlareColor = float3( 0.0f, 0.0f, 0.0f );

        UNROLL
        for( int i = 0; i < 4; i++ )
        {
            float2 OffsetUV = UV + GlarePixelSize * Coords[i];
            GlareColor.rgb += 0.25f * Texture2DSample( GlareTexture, InputSampler, OffsetUV ).rgb;
        }

        Flares += GlareColor;
    }

// Colored gradientconst float2 Center = float2( 0.5f, 0.5f );
    float2 GradientUV = float2(
        saturate( distance(UV, Center) * 2.0f ),
        0.0f
    );

    float3 Gradient = Texture2DSample( FlareGradientTexture, FlareGradientSampler, GradientUV ).rgb;

    Flares *= Gradient * FlareTint.rgb * FlareIntensity;

//---------------------------------------// Add Glare and Flares to final mix//---------------------------------------OutColor.rgb += Flares;
}

 

단계 6: 블룸 렌더링

기본이 마련되었으니 이제 실제 블룸 코드로 들어갈 수 있습니다. 다음 코드는 여전히 PostProcessSubsystem.cpp 안에 있습니다.

 

RenderBloom Function

FScreenPassTexture UPostProcessSubsystem::RenderBloom(
    FRDGBuilder& GraphBuilder,
    const FViewInfo& View,
    const FScreenPassTexture& SceneColor,
    int32 PassAmount
)
{
    check( SceneColor.IsValid() );

    if( PassAmount <= 1 )
    {
        return FScreenPassTexture();
    }

    RDG_EVENT_SCOPE(GraphBuilder, "BloomPass");

[...]

여기서 주목해야 할 중요한 사항은 다음과 같습니다:

  • PassAmount: 우리는 패스의 양이 2보다 큰지 확인합니다. 이는 이전 결과를 읽고 현재 결과와 결합하는 방식으로 업샘플 패스 코드가 작동하기 때문에 1개의 패스는 작동하지 않을 것입니다(두 개의 입력이 필요합니다).
  • *FScreenPassTexture()**를 반환하는 것은 초기화되지 않은 구조체(텍스처 없음)를 반환하는 것을 의미합니다. 따라서 IsValid() 확인이 실패하여 믹싱 중에 BlackDummy로 대체할 수 있습니다.
[...]
//----------------------------------------------------------// Downsample//----------------------------------------------------------int32 Width = View.ViewRect.Width();
    int32 Height = View.ViewRect.Height();
    int32 Divider = 2;
    FRDGTextureRef PreviousTexture = SceneColor.Texture;

    for( int32 i = 0; i < PassAmount; i++ )
    {
        FIntRect Size{
            0,
            0,
            FMath::Max( Width / Divider, 1 ),
            FMath::Max( Height / Divider, 1 )
        };

        const FString PassName  = "Downsample_"
                                + FString::FromInt( i )
                                + "_(1/"
                                + FString::FromInt( Divider )
                                + ")_"
                                + FString::FromInt( Size.Width() )
                                + "x"
                                + FString::FromInt( Size.Height() );

        FRDGTextureRef Texture = nullptr;

// The SceneColor input is already downscaled by the engine// so we just reference it and continue.if( i == 0 )
        {
            Texture = PreviousTexture;
        }
        else
        {
            Texture = RenderDownsample(
                GraphBuilder,
                PassName,
                View,
                PreviousTexture,
                Size
            );
        }

        FScreenPassTexture DownsampleTexture( Texture, Size );

        MipMapsDownsample.Add( DownsampleTexture );
        PreviousTexture = Texture;
        Divider *= 2;
    }
[...]

그래서 여기에서 우리는 다운샘플을 수행합니다. 각 다운샘플은 이전 결과를 재사용하기 때문에 PreviousTexture 변수가 있습니다. divider는 항상 다음 낮은 2의 거듭제곱 해상도로 렌더링되도록 보장합니다.

각 렌더 패스 결과는 나중에 액세스하기 위해 MipMapsDownsample 배열에 참조됩니다. 이 배열은 함수가 아닌 서브시스템 레벨에서 선언되었으며 다른 패스(특히 사용자 정의 렌즈 플레어)에서 액세스할 수 있도록합니다.

[...]

//----------------------------------------------------------// Upsample//----------------------------------------------------------float Radius = CVarBloomRadius.GetValueOnRenderThread();

// Copy downsamples into upsample so that// we can easily access current and previous// inputs during the upsample processMipMapsUpsample.Append( MipMapsDownsample );

// Stars at -2 since we need the last buffer// as the previous input (-2) and the one just// before as the current input (-1).// We also go from end to start of array to// go from small to big texture (going back up the mips)for( int32 i = PassAmount - 2; i >= 0; i-- )
    {
        FIntRect CurrentSize = MipMapsUpsample[i].ViewRect;

        const FString PassName  = "UpsampleCombine_"
                                + FString::FromInt( i )
                                + "_"
                                + FString::FromInt( CurrentSize.Width() )
                                + "x"
                                + FString::FromInt( CurrentSize.Height() );

        FRDGTextureRef ResultTexture = RenderUpsampleCombine(
            GraphBuilder,
            PassName,
            View,
            MipMapsUpsample[i],// Current textureMipMapsUpsample[i + 1],// Previous texture,Radius
        );

        FScreenPassTexture NewTexture( ResultTexture, CurrentSize );
        MipMapsUpsample[i] = NewTexture;
    }

    return MipMapsUpsample[0];
}// end of RenderBloom()

업샘플링은 매우 간단하지만, 코드 레이아웃은 처음에 이해하기 어려울 수 있습니다:

  • 렌더 프로세스는 이전의 결합된 결과와 현재 다운샘플 레벨을 사용하여 새로운 결합된 결과를 생성합니다. 즉, A + B = C, 그리고 D + C = F 등이 됩니다.
  • 또한, 우리는 작은 텍스처 해상도(배열의 끝)에서 큰 텍스처(배열의 시작)로 이동합니다. 따라서 루프의 시작점으로 음수를 사용합니다.

Downsample Function

FRDGTextureRef UPostProcessSubsystem::RenderDownsample(
    FRDGBuilder& GraphBuilder,
    const FString& PassName,
    const FViewInfo& View,
    FRDGTextureRef InputTexture,
    const FIntRect& Viewport
)
{
// Build textureFRDGTextureDesc Description = InputTexture->Desc;
    Description.Reset();
    Description.Extent  = Viewport.Size();
    Description.Format  = PF_FloatRGB;
    Description.ClearValue = FClearValueBinding(FLinearColor::Black);
    FRDGTextureRef TargetTexture = GraphBuilder.CreateTexture(Description, *PassName);

// Render shaderTShaderMapRef<FCustomScreenPassVS> VertexShader(View.ShaderMap);
    TShaderMapRef<FDownsamplePS> PixelShader(View.ShaderMap);

    FDownsamplePS::FParameters* PassParameters = GraphBuilder.AllocParameters<FDownsamplePS::FParameters>();

    PassParameters->Pass.InputTexture       = InputTexture;
    PassParameters->Pass.RenderTargets[0]   = FRenderTargetBinding(TargetTexture, ERenderTargetLoadAction::ENoAction);
    PassParameters->InputSampler            = BilinearBorderSampler;
    PassParameters->InputSize               = FVector2D( Viewport.Size() );

    DrawShaderPass(
        GraphBuilder,
        PassName,
        PassParameters,
        VertexShader,
        PixelShader,
        ClearBlendState,
        Viewport
    );

    return TargetTexture;
}

아무런 특별한 것은 없습니다. 우리는 그저 셰이더 패스를 렌더링합니다.


Upsample Function

FRDGTextureRef UPostProcessSubsystem::RenderUpsampleCombine(
    FRDGBuilder& GraphBuilder,
    const FString& PassName,
    const FViewInfo& View,
    const FScreenPassTexture& InputTexture,
    const FScreenPassTexture& PreviousTexture,
    float Radius
)
{
// Build textureFRDGTextureDesc Description = InputTexture.Texture->Desc;
    Description.Reset();
    Description.Extent  = InputTexture.ViewRect.Size();
    Description.Format  = PF_FloatRGB;
    Description.ClearValue = FClearValueBinding(FLinearColor::Black);
    FRDGTextureRef TargetTexture = GraphBuilder.CreateTexture(Description, *PassName);

    TShaderMapRef<FCustomScreenPassVS> VertexShader(View.ShaderMap);
    TShaderMapRef<FUpsampleCombinePS> PixelShader(View.ShaderMap);

    FUpsampleCombinePS::FParameters* PassParameters = GraphBuilder.AllocParameters<FUpsampleCombinePS::FParameters>();

    PassParameters->Pass.InputTexture       = InputTexture.Texture;
    PassParameters->Pass.RenderTargets[0]   = FRenderTargetBinding(TargetTexture, ERenderTargetLoadAction::ENoAction);
    PassParameters->InputSampler            = BilinearClampSampler;
    PassParameters->InputSize               = FVector2D( PreviousTexture.ViewRect.Size() );
    PassParameters->PreviousTexture         = PreviousTexture.Texture;
    PassParameters->Radius                  = Radius;

    DrawShaderPass(
        GraphBuilder,
        PassName,
        PassParameters,
        VertexShader,
        PixelShader,
        ClearBlendState,
        InputTexture.ViewRect
    );

    return TargetTexture;

렌더링 패스를 생성하는 방법은 동일합니다.


거기에서는 필요한 모든 것을 갖춘 블룸 효과를 만들 수 있을 것입니다.


Performance

현재 상태에서의 성능은 다음과 같습니다(제 RX 5600 XT에서 1080p 기준):

(기본 UE4 블룸 및 렌즈 플레어, 총 0.794ms 정도)

 

(새로운 커스텀 블룸 및 렌즈 플레어(v2), 약 0.723ms)

위에서 볼 수 있듯이, 우리는 원본 엔진의 렌더링 시간에 근접해 있으며 훨씬 더 품질과 외관이 좋은 최종 효과를 얻었습니다! 저는 컴퓨트 셰이더를 사용하여 이를 더욱 낮출 수 있다고 확신합니다. 예를 들어 UE4의 원본 다운샘플링은 단지 5단계로만 0.075ms입니다.

참고: 렌즈 플레어의 두 번째 버전은 여러 효과를 하나의 패스로 결합하였기 때문에, 위의 이미지에는 헤일로와 같은 몇 가지 요소가 더 이상 언급되지 않았지만 최종 렌더링에는 여전히 존재합니다.

(You can compare this result against the&nbsp;old effect.)

https://www.froyok.fr/blog/2021-12-ue4-custom-bloom/resources/comparison/bloom-flares_shooter_old.jpg

보너스

아직 질문이 남아있는 경우에 대비하여 몇 가지 빠른 보너스를 제공합니다.

권장 설정

당연히, 내가 한 모든 것을 따랐다면, PostProcess 볼륨에서의 Bloom 강도 1.0은 선형 보간 때문에 흐린 화면으로 이어질 것입니다.

0.3보다 높게 설정하는 것을 권장하지 않지만, 극단적인 경우에는 0.5를 시도해 볼 수 있습니다. 그래도 매우 흐린 화면은 시각적 효과를 위해 유용할 수도 있습니다 (시야 흐려짐/손상 시뮬레이션과 같은).

따라서 매우 와이드한 Bloom이 필요하다면, 내용 측면에서 발광 라이트/표면의 강도를 높이는 것을 권장합니다. 이제 빛이 더 이상 추가되지 않기 때문에, 그들은 흰색으로 포화되기 전까지 더 많은 시간을 소요합니다.

또한 만약 먼지 마스크 텍스처를 사용한다면, 그 강도가 1.0을 초과하지 않도록 확인하세요 (물론 매우 어두운 텍스처인 경우를 제외하고). 매우 "밝은" 먼지 마스크는 톤매핑 패스에서 이제 어두운 곳에서도 나타날 것입니다.


커스텀 블룸과 커스텀 렌즈 플레어를 결합하기

이전 글에서 제시한 렌즈 플레어를 최적화하고 싶다면, 다음과 같이 내가 한 것을 복제하는 몇 가지 힌트가 있습니다:

  1. RenderThreshold() 함수를 완전히 제거할 수 있습니다. 새로운 블룸은 (lerp 블렌딩을 사용한다면) 혼합 패스에서 장면 색상에 플레어를 포함시키기 때문에 의미가 없습니다.
  2. 블러 처리를 수동으로 하지 않고 Ghosts 렌더 패스에 두 번째 또는 세 번째 다운샘플 결과를 대체로 사용하여 흐린 버전을 얻을 수 있습니다. 원하는 만큼 다양성을 얻기 위해 여러 다운샘플을 사용할 수도 있습니다.
  3. Ghosts와 Halo 렌더 패스를 병합하세요. Halo와 마찬가지로 Ghosts 크로마틱 블러를 동일한 패스에서 수행하세요. 이러한 효과를 분리하는 것은 실제로 혜택이 없으며, 모든 것을 병합하는 것이 더 빠릅니다.
  4. 병합된 Ghost와 Halo 패스 이후에 블러 패스를 계속 수행하여 시각적 품질을 향상시키세요.