본문 바로가기

ShaderStudy발표자료

[번역] UE5의 카툰 렌더링 - 커스텀 셰이딩 모델 만들기

728x90
반응형
  • 셰이딩 모델의 추가하는 가장 간단한 방법 -> 엔진 코드 수정 
  • 중국 사이트 Zhihu에는 Shading Model의 코드를 확장하는 방법이 아주 많이 나옴
  • 실제로 코드로 적용해보자!
  • 그리고 효과, 그리고 그 원리를 간단하게 알아보자
  • 아래 참고자료를 보고 번역했습니다. (구글 번역, DeepL 짱!)

 

셰이딩 모델은 최종적으로 머티리얼에 표현되어야 하며 다음 단계가 필요합니다.

  1. 상위 재질에 사용하려는 셰이딩 모델을 선택
  2. PixelMaterialInputs선택 후, 셰이딩 모델의 값은 PS에서 이 매개변수를 얻을 수 있도록 머트리얼의 입력 매개변수로 전달
  3. 디퍼드 렌더링이기 때문에 동시에 셰이딩 모델의 값도 SetGBufferForShadingModel함수를 통해 GBuffer에 전달
  4. 매개변수 입력을 처리한 후 IntegrateBxDF조명 계산 함수에서 전달된 Shading Model 값에 따라 최종적으로 해당 BxDF 함수를 선택하여 조명 계산을 수행
  5. 마지막 단계는 맞춤형 BxDF 조명 계산을 실행하는 것

위의 단계를 통해 Shading Model에서 정의한 조명 계산을 최종적으로 머트리얼에서 호출하여 랜더링 할 수 있습니다.

ps. 샘플 기준이 SM6 디퍼드 랜더링 기준으로 작업이 되어 있습니다. 모바일은 내용에 없습니다.

저도 따라해본게 다라.... 깊게 설명하는 부분은 패스합니다. 참고자료를 봐주세요.

언리얼 5.1 버젼 기준으로 작성되었습니다.

 

 

  • enum EMaterialShadingModel 에 Toon 추가
//EngineTypes.h
UENUM()
enum EMaterialShadingModel : int
{
	MSM_Unlit					UMETA(DisplayName="Unlit"),
	MSM_DefaultLit				UMETA(DisplayName="Default Lit"),
	MSM_Subsurface				UMETA(DisplayName="Subsurface"),
	MSM_PreintegratedSkin		UMETA(DisplayName="Preintegrated Skin"),
	MSM_ClearCoat				UMETA(DisplayName="Clear Coat"),
	MSM_SubsurfaceProfile		UMETA(DisplayName="Subsurface Profile"),
	MSM_TwoSidedFoliage			UMETA(DisplayName="Two Sided Foliage"),
	MSM_Hair					UMETA(DisplayName="Hair"),
	MSM_Cloth					UMETA(DisplayName="Cloth"),
	MSM_Eye						UMETA(DisplayName="Eye"),
	MSM_SingleLayerWater		UMETA(DisplayName="SingleLayerWater"),
	MSM_ThinTranslucent			UMETA(DisplayName="Thin Translucent"),
	MSM_Strata					UMETA(DisplayName="Strata", Hidden),    
	MSM_Toon					UMETA(DisplayName="Toon"), //Toon 추가
	/** Number of unique shading models. */
	MSM_NUM						UMETA(Hidden),
	/** Shading model will be determined by the Material Expression Graph,
		by utilizing the 'Shading Model' MaterialAttribute output pin. */
	MSM_FromMaterialExpression	UMETA(DisplayName="From Material Expression"),
	MSM_MAX
};

 

  • GetShadingModelString 에 이름 추가
//MaterialShader.cpp
FString GetShadingModelString(EMaterialShadingModel ShadingModel)
{
	FString ShadingModelName;
	switch(ShadingModel)
	{
		case MSM_Unlit:				ShadingModelName = TEXT("MSM_Unlit"); break;
		case MSM_DefaultLit:		ShadingModelName = TEXT("MSM_DefaultLit"); break;
		case MSM_Subsurface:		ShadingModelName = TEXT("MSM_Subsurface"); break;
		case MSM_PreintegratedSkin:	ShadingModelName = TEXT("MSM_PreintegratedSkin"); break;
		case MSM_ClearCoat:			ShadingModelName = TEXT("MSM_ClearCoat"); break;
		case MSM_SubsurfaceProfile:	ShadingModelName = TEXT("MSM_SubsurfaceProfile"); break;
		case MSM_TwoSidedFoliage:	ShadingModelName = TEXT("MSM_TwoSidedFoliage"); break;
		case MSM_Hair:				ShadingModelName = TEXT("MSM_Hair"); break;
		case MSM_Cloth:				ShadingModelName = TEXT("MSM_Cloth"); break;
		case MSM_Eye:				ShadingModelName = TEXT("MSM_Eye"); break;
		case MSM_SingleLayerWater:	ShadingModelName = TEXT("MSM_SingleLayerWater"); break;
		case MSM_ThinTranslucent:	ShadingModelName = TEXT("MSM_ThinTranslucent"); break;
		case MSM_Toon:				ShadingModelName = TEXT("MSM_Toon"); break; //추가
		default: ShadingModelName = TEXT("Unknown"); break;
	}
	return ShadingModelName;
}

 

  • 쉐이더 매크로 정의 추가
//HLSLMaterialTranslator.cpp
if (ShadingModels.HasShadingModel(MSM_SingleLayerWater))
{
    OutEnvironment.SetDefine(TEXT("MATERIAL_SHADINGMODEL_SINGLELAYERWATER"), TEXT("1"));
    NumSetMaterials++;
}
if (ShadingModels.HasShadingModel(MSM_ThinTranslucent))
{
    OutEnvironment.SetDefine(TEXT("MATERIAL_SHADINGMODEL_THIN_TRANSLUCENT"), TEXT("1"));
    NumSetMaterials++;

    bMaterialRequestsDualSourceBlending = true;
}
//추가
if (ShadingModels.HasShadingModel(MSM_Toon)) 
{
    OutEnvironment.SetDefine(TEXT("MATERIAL_SHADINGMODEL_TOON"), TEXT("1"));
    NumSetMaterials++;
}

if (ShadingModels.HasShadingModel(MSM_SingleLayerWater) && FDataDrivenShaderPlatformInfo::GetRequiresDisableForwardLocalLights(Platform))
{
    OutEnvironment.SetDefine(TEXT("DISABLE_FORWARD_LOCAL_LIGHTS"), TEXT("1"));
}
//ShaderMaterial.h
struct FShaderMaterialPropertyDefines
{
	//DECLARE_TYPE_LAYOUT(FShaderMaterialPropertyDefines, NonVirtual);

	//void ModifyEnvironment(FShaderCompilerEnvironment& OutEnvironment) const;
	//void WriteFrozenVertexFactoryParameters(FMemoryImageWriter& Writer, const TMemoryImagePtr<FShaderMaterialPropertyDefines>& InPropDefines) const;

	uint8 MATERIAL_ENABLE_TRANSLUCENCY_FOGGING : 1;

	uint8 MATERIALBLENDING_ANY_TRANSLUCENT : 1;
	uint8 MATERIAL_USES_SCENE_COLOR_COPY : 1;
	uint8 MATERIALBLENDING_MASKED_USING_COVERAGE : 1;

	uint8 MATERIAL_COMPUTE_FOG_PER_PIXEL : 1;
	uint8 MATERIAL_SHADINGMODEL_UNLIT : 1;

	uint8 MATERIAL_SHADINGMODEL_DEFAULT_LIT : 1;
	uint8 MATERIAL_SHADINGMODEL_SUBSURFACE : 1;
	uint8 MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN : 1;
	uint8 MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE : 1;
	uint8 MATERIAL_SHADINGMODEL_CLEAR_COAT : 1;
	uint8 MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE : 1;
	uint8 MATERIAL_SHADINGMODEL_HAIR : 1;
	uint8 MATERIAL_SHADINGMODEL_CLOTH : 1;
	uint8 MATERIAL_SHADINGMODEL_EYE : 1;
	uint8 MATERIAL_SHADINGMODEL_SINGLELAYERWATER : 1;
	uint8 SINGLE_LAYER_WATER_SEPARATED_MAIN_LIGHT : 1;
	uint8 MATERIAL_SHADINGMODEL_THIN_TRANSLUCENT : 1;

	uint8 MATERIAL_SHADINGMODEL_TOON : 1; //추가

 

  • 쉐이더 컴파일 코드 생성

 

//ShaderGenerationUtil.cpp
void FShaderCompileUtilities::ApplyFetchEnvironment(FShaderMaterialPropertyDefines& SrcDefines, FShaderCompilerEnvironment& OutEnvironment)
{
	FETCH_COMPILE_BOOL(MATERIAL_ENABLE_TRANSLUCENCY_FOGGING);
	FETCH_COMPILE_BOOL(MATERIALBLENDING_ANY_TRANSLUCENT);
	FETCH_COMPILE_BOOL(MATERIAL_USES_SCENE_COLOR_COPY);
	FETCH_COMPILE_BOOL(MATERIALBLENDING_MASKED_USING_COVERAGE);

	FETCH_COMPILE_BOOL(MATERIAL_COMPUTE_FOG_PER_PIXEL);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_UNLIT);

	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_DEFAULT_LIT);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_SUBSURFACE);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_CLEAR_COAT);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_HAIR);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_CLOTH);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_EYE);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_SINGLELAYERWATER);
	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_THIN_TRANSLUCENT);

	FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_TOON); //추가

	FETCH_COMPILE_BOOL(SINGLE_LAYER_WATER_SEPARATED_MAIN_LIGHT);
	FETCH_COMPILE_BOOL(MATERIAL_FULLY_ROUGH);
	FETCH_COMPILE_BOOL(USES_EMISSIVE_COLOR);

 

  • 쉐이더 모델이 추가 데이터 사용하는 경우 CustomData를 통해 매개변수를 전달 할수 있다.
  • 머트리얼의 노드의 핀을 수정할수도 있다.
  • 쉐이딩 모델 아래의 Custom 핀을 활성화 한다.
//Material.cpp
case MP_Normal:
    Active = (ShadingModels.IsLit() && (!bIsTranslucentBlendMode || !bIsNonDirectionalTranslucencyLightingMode)) || bUsesDistortion;
    break;
case MP_Tangent:
    Active = ShadingModels.HasAnyShadingModel({ MSM_DefaultLit, MSM_ClearCoat }) && (!bIsTranslucentBlendMode || !bIsVolumetricTranslucencyLightingMode);
    break;
case MP_SubsurfaceColor:
    Active = ShadingModels.HasAnyShadingModel({ MSM_Subsurface, MSM_PreintegratedSkin, MSM_TwoSidedFoliage, MSM_Cloth });
    break;
case MP_CustomData0: //추가
    Active = ShadingModels.HasAnyShadingModel({ MSM_ClearCoat, MSM_Hair, MSM_Cloth, MSM_Eye, MSM_SubsurfaceProfile, MSM_Toon });
    break;
case MP_CustomData1: //추가
    Active = ShadingModels.HasAnyShadingModel({ MSM_ClearCoat, MSM_Eye, MSM_Toon });
    break;

 

  • 핀에 대해 알아두면 좋은 점
  • 핀 이름(예: customdata0 )을 더 나은 이름으로 바꿔야 하는 경우 해당 이름은 MaterialShared.cpp 파일 과 FMaterialAttributeDefinitionMap::GetAttributeOverrideForMaterial() 함수 내에 있습니다 .
  • 새 핀 추가 이 코드는 원래 MaterialShared.cpp 파일 아래에 배치되었지만 주요 개발 브랜치의 최신 버전에서는 이를 MaterialAttributeDefinitionMap.cpp 아래로 이동했습니다
//MaterialShared.cpp/MaterialAttributeDefinitionMap.cpp
case MP_CustomData0:	
    CustomPinNames.Add({ MSM_ClearCoat, "Clear Coat" });
    CustomPinNames.Add({MSM_Hair, "Backlit"});
    CustomPinNames.Add({MSM_Cloth, "Cloth"});
    CustomPinNames.Add({MSM_Eye, "Iris Mask"});
    CustomPinNames.Add({MSM_SubsurfaceProfile, "Curvature" });
	//쉐이딩모델을 Toon으로 바꾸었을때 CustomData0 이름출력이 Specular Range으로 변경
    CustomPinNames.Add({ MSM_Toon, "Specular Range" }); //추가
    
    return FText::FromString(GetPinNameFromShadingModelField(Material->GetShadingModels(), CustomPinNames, "Custom Data 0"));
case MP_CustomData1:
    CustomPinNames.Add({ MSM_ClearCoat, "Clear Coat Roughness" });
    CustomPinNames.Add({MSM_Eye, "Iris Distance"});
  	//쉐이딩모델을 Toon으로 바꾸었을때 CustomData1 이름출력이 Offset으로 변경
    CustomPinNames.Add({ MSM_Toon, "Offset" }); //추가
    
    return FText::FromString(GetPinNameFromShadingModelField(Material->GetShadingModels(), CustomPinNames, "Custom Data 1"));

 

  • GBuffer에 CustomData 쓰기
//ShaderGenerationUtil.cpp
if (Mat.MATERIAL_SHADINGMODEL_EYE)
{
    SetStandardGBufferSlots(Slots, bWriteEmissive, bHasTangent, bHasVelocity, bHasStaticLighting, bIsStrataMaterial);
    Slots[GBS_CustomData] = bUseCustomData;
}

if (Mat.MATERIAL_SHADINGMODEL_SINGLELAYERWATER)
{
    // single layer water uses standard slots
    SetStandardGBufferSlots(Slots, bWriteEmissive, bHasTangent, bHasVelocity, bHasStaticLighting, bIsStrataMaterial);
    if (Mat.SINGLE_LAYER_WATER_SEPARATED_MAIN_LIGHT)
    {
        Slots[GBS_SeparatedMainDirLight] = true;
    }
}

// doesn't write to GBuffer
if (Mat.MATERIAL_SHADINGMODEL_THIN_TRANSLUCENT)
{
}
if (Mat.MATERIAL_SHADINGMODEL_TOON) //추가
{
    SetStandardGBufferSlots(Slots, bWriteEmissive, bHasTangent, bHasVelocity, bHasStaticLighting, bIsStrataMaterial);
    Slots[GBS_CustomData] = bUseCustomData;
}
//ShaderMaterialDerivedHelpers.cpp
Dst.NEEDS_LIGHTMAP = (Dst.NEEDS_LIGHTMAP_COORDINATE) && !Lightmap.PRECOMPUTED_IRRADIANCE_VOLUME_LIGHTING;

Dst.USES_GBUFFER = (FEATURE_LEVEL >= ERHIFeatureLevel::SM4_REMOVED && (Mat.MATERIALBLENDING_SOLID || Mat.MATERIALBLENDING_MASKED) && !SrcGlobal.FORWARD_SHADING);

// Only some shader models actually need custom data. //추가
Dst.WRITES_CUSTOMDATA_TO_GBUFFER = (Dst.USES_GBUFFER && (Mat.MATERIAL_SHADINGMODEL_SUBSURFACE || Mat.MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN || Mat.MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE || Mat.MATERIAL_SHADINGMODEL_CLEAR_COAT || Mat.MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE || Mat.MATERIAL_SHADINGMODEL_HAIR || Mat.MATERIAL_SHADINGMODEL_CLOTH || Mat.MATERIAL_SHADINGMODEL_EYE || Mat.MATERIAL_SHADINGMODEL_TOON));

 

  • C++ 부분에 대한 변경은 기본적으로 끝났습니다.
  • 결과를 보면 머티리얼 에디터에 Shading Model 옵션을 추가했습니다.
  • 동시에 이 Shading Model을 선택하면 두 개의 사용자 정의 핀이 나타납니다.
  • 이 두 핀은 계산에 사용할 수 있도록 CustomData를 통해 셰이더에 매개변수를 전달합니다.

 

  • 다음은 셰이더 부분입니다. 우선 동일한 작업을 수행할 수 있습니다.
  • 다른 셰이딩 모델의 이름을 전역적으로 검색하고 먼저 몇 가지 정의를 추가할 수 있습니다.
//Definitions.usf
#if STRATA_NORMAL_QUALITY==0
#define STRATA_TOP_LAYER_TYPE uint
#elif STRATA_NORMAL_QUALITY==1
#define STRATA_TOP_LAYER_TYPE uint2
#else
#error Uknown STRATA_NORMAL_QUALITY
#endif

#ifndef MATERIAL_SHADINGMODEL_DEFAULT_LIT
#define MATERIAL_SHADINGMODEL_DEFAULT_LIT				0
#endif
//추가
#ifndef MATERIAL_SHADINGMODEL_TOON
#define MATERIAL_SHADINGMODEL_TOON						0
#endif

#ifndef MATERIAL_SHADINGMODEL_SUBSURFACE
#define MATERIAL_SHADINGMODEL_SUBSURFACE				0
#endif

#ifndef MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN
#define MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN		0
#endif

 

  • Shading Model ID를 추가하고 View에서 Debug의 색상을 정의합니다.
//ShadingCommon.ush
// SHADINGMODELID_* occupy the 4 low bits of an 8bit channel and SKIP_* occupy the 4 high bits
#define SHADINGMODELID_UNLIT				0
#define SHADINGMODELID_DEFAULT_LIT			1
#define SHADINGMODELID_SUBSURFACE			2
#define SHADINGMODELID_PREINTEGRATED_SKIN	3
#define SHADINGMODELID_CLEAR_COAT			4
#define SHADINGMODELID_SUBSURFACE_PROFILE	5
#define SHADINGMODELID_TWOSIDED_FOLIAGE		6
#define SHADINGMODELID_HAIR					7
#define SHADINGMODELID_CLOTH				8
#define SHADINGMODELID_EYE					9
#define SHADINGMODELID_SINGLELAYERWATER		10
#define SHADINGMODELID_THIN_TRANSLUCENT		11
#define SHADINGMODELID_STRATA				12		// Temporary while we convert everything to Strata
#define SHADINGMODELID_TOON					13		// 추가
#define SHADINGMODELID_NUM					14
#define SHADINGMODELID_MASK					0xF		// 4 bits reserved for ShadingModelID			

// The flags are defined so that 0 value has no effect!
// These occupy the 4 high bits in the same channel as the SHADINGMODELID_*
#define HAS_ANISOTROPY_MASK				(1 << 4)
#define SKIP_PRECSHADOW_MASK			(1 << 5)
#define ZERO_PRECSHADOW_MASK			(1 << 6)
#define SKIP_VELOCITY_MASK				(1 << 7)

#define SSS_PROFILE_ID_INVALID  256
#define SSS_PROFILE_ID_PERPIXEL 512

// for debugging and to visualize
float3 GetShadingModelColor(uint ShadingModelID)
{
	// TODO: PS4 doesn't optimize out correctly the switch(), so it thinks it needs all the Samplers even if they get compiled out
	//	This will get fixed after launch per Sony...
#if PS4_PROFILE
		 if (ShadingModelID == SHADINGMODELID_UNLIT) return float3(0.1f, 0.1f, 0.2f); // Dark Blue
	else if (ShadingModelID == SHADINGMODELID_DEFAULT_LIT) return float3(0.1f, 1.0f, 0.1f); // Green
	else if (ShadingModelID == SHADINGMODELID_SUBSURFACE) return float3(1.0f, 0.1f, 0.1f); // Red
	else if (ShadingModelID == SHADINGMODELID_PREINTEGRATED_SKIN) return float3(0.6f, 0.4f, 0.1f); // Brown
	else if (ShadingModelID == SHADINGMODELID_CLEAR_COAT) return float3(0.1f, 0.4f, 0.4f); 
	else if (ShadingModelID == SHADINGMODELID_SUBSURFACE_PROFILE) return float3(0.2f, 0.6f, 0.5f); // Cyan
	else if (ShadingModelID == SHADINGMODELID_TWOSIDED_FOLIAGE) return float3(0.2f, 0.2f, 0.8f); // Blue
	else if (ShadingModelID == SHADINGMODELID_HAIR) return float3(0.6f, 0.1f, 0.5f);
	else if (ShadingModelID == SHADINGMODELID_CLOTH) return float3(0.7f, 1.0f, 1.0f); 
	else if (ShadingModelID == SHADINGMODELID_EYE) return float3(0.3f, 1.0f, 1.0f); 
	else if (ShadingModelID == SHADINGMODELID_SINGLELAYERWATER) return float3(0.5f, 0.5f, 1.0f);
	else if (ShadingModelID == SHADINGMODELID_THIN_TRANSLUCENT) return float3(1.0f, 0.8f, 0.3f);
	else if (ShadingModelID == SHADINGMODELID_STRATA) return float3(1.0f, 1.0f, 0.0f);
	else if (ShadingModelID == SHADINGMODELID_TOON) return float3(0.75f, 0.1f, 0.1f); //추가
	else return float3(1.0f, 1.0f, 1.0f); // White
#else
	switch(ShadingModelID)
	{
		case SHADINGMODELID_UNLIT: return float3(0.1f, 0.1f, 0.2f); // Dark Blue
		case SHADINGMODELID_DEFAULT_LIT: return float3(0.1f, 1.0f, 0.1f); // Green
		case SHADINGMODELID_SUBSURFACE: return float3(1.0f, 0.1f, 0.1f); // Red
		case SHADINGMODELID_PREINTEGRATED_SKIN: return float3(0.6f, 0.4f, 0.1f); // Brown
		case SHADINGMODELID_CLEAR_COAT: return float3(0.1f, 0.4f, 0.4f); // Brown
		case SHADINGMODELID_SUBSURFACE_PROFILE: return float3(0.2f, 0.6f, 0.5f); // Cyan
		case SHADINGMODELID_TWOSIDED_FOLIAGE: return float3(0.2f, 0.2f, 0.8f); // Cyan
		case SHADINGMODELID_HAIR: return float3(0.6f, 0.1f, 0.5f);
		case SHADINGMODELID_CLOTH: return float3(0.7f, 1.0f, 1.0f);
		case SHADINGMODELID_EYE: return float3(0.3f, 1.0f, 1.0f);
		case SHADINGMODELID_SINGLELAYERWATER: return float3(0.5f, 0.5f, 1.0f);
		case SHADINGMODELID_THIN_TRANSLUCENT: return float3(1.0f, 0.8f, 0.3f);
		case SHADINGMODELID_STRATA: return float3(1.0f, 1.0f, 0.0f);
		case SHADINGMODELID_TOON: return float3(0.75f, 0.75f, 0.1f); //추가
		default: return float3(1.0f, 1.0f, 1.0f); // White
	}
#endif
}

 

  • CustomData가 작성되었는지 확인
//DeferredShadingCommon.ush
bool HasCustomGBufferData(int ShadingModelID)
{
	return ShadingModelID == SHADINGMODELID_SUBSURFACE
		|| ShadingModelID == SHADINGMODELID_PREINTEGRATED_SKIN
		|| ShadingModelID == SHADINGMODELID_CLEAR_COAT
		|| ShadingModelID == SHADINGMODELID_SUBSURFACE_PROFILE
		|| ShadingModelID == SHADINGMODELID_TWOSIDED_FOLIAGE
		|| ShadingModelID == SHADINGMODELID_HAIR
		|| ShadingModelID == SHADINGMODELID_CLOTH
		|| ShadingModelID == SHADINGMODELID_EYE
		|| ShadingModelID == SHADINGMODELID_TOON; //추가
}

 

  • CustomData를 GBuffer에 쓰기 위한 쓰기 정의
//BasePassCommon.ush
#define NEEDS_LIGHTMAP						(NEEDS_LIGHTMAP_COORDINATE)

#define USES_GBUFFER						(FEATURE_LEVEL >= FEATURE_LEVEL_SM4 && (MATERIALBLENDING_SOLID || MATERIALBLENDING_MASKED) && !FORWARD_SHADING)

// Only some shader models actually need custom data. //추가
#define WRITES_CUSTOMDATA_TO_GBUFFER		(USES_GBUFFER && (MATERIAL_SHADINGMODEL_SUBSURFACE || MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN || MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE || MATERIAL_SHADINGMODEL_CLEAR_COAT || MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE || MATERIAL_SHADINGMODEL_HAIR || MATERIAL_SHADINGMODEL_CLOTH || MATERIAL_SHADINGMODEL_EYE || MATERIAL_SHADINGMODEL_TOON))

 

디버그 뷰에서 나오는 모델

 

  • GBuffer에 CustomData 쓰기
//ShadingModelsMaterial.ush
#if MATERIAL_SHADINGMODEL_EYE
	else if (ShadingModel == SHADINGMODELID_EYE)
	{
		const float IrisMask     = saturate(GetMaterialCustomData0(MaterialParameters));
		const float IrisDistance = saturate(GetMaterialCustomData1(MaterialParameters));

		GBuffer.CustomData.x = EncodeSubsurfaceProfile(SubsurfaceProfile).x;
		GBuffer.CustomData.w = 1.0f - IrisMask;	// Opacity

	#if IRIS_NORMAL
		float2 WorldNormalOct = UnitVectorToOctahedron( GBuffer.WorldNormal );

		// CausticNormal stored as octahedron
		#if NUM_MATERIAL_OUTPUTS_GETTANGENTOUTPUT > 0
			// Blend in the negative intersection normal to create some concavity
			// Not great as it ties the concavity to the convexity of the cornea surface
			// No good justification for that. On the other hand, if we're just looking to
			// introduce some concavity, this does the job.
			float3 PlaneNormal = normalize( GetTangentOutput0(MaterialParameters) );
			float3 CausticNormal = normalize( lerp( PlaneNormal, -GBuffer.WorldNormal, IrisMask*IrisDistance ) );
			float2 CausticNormalOct  = UnitVectorToOctahedron( CausticNormal );
			float2 CausticNormalDelta = ( CausticNormalOct - WorldNormalOct ) * 0.5 + (128.0/255.0);
			GBuffer.Metallic = CausticNormalDelta.x;
			GBuffer.Specular = CausticNormalDelta.y;
		#else
			float3 PlaneNormal = GBuffer.WorldNormal;
			GBuffer.Metallic = 128.0/255.0;
			GBuffer.Specular = 128.0/255.0;
		#endif

		// IrisNormal CustomData.yz
		#if NUM_MATERIAL_OUTPUTS_CLEARCOATBOTTOMNORMAL > 0
			float3 IrisNormal = normalize( ClearCoatBottomNormal0(MaterialParameters) );
			#if MATERIAL_TANGENTSPACENORMAL
				IrisNormal = normalize( TransformTangentVectorToWorld( MaterialParameters.TangentToWorld, IrisNormal ) );
			#endif
		#else
			float3 IrisNormal = PlaneNormal;
		#endif

		float2 IrisNormalOct  = UnitVectorToOctahedron( IrisNormal );
		float2 IrisNormalDelta = ( IrisNormalOct - WorldNormalOct ) * 0.5 + (128.0/255.0);
		GBuffer.CustomData.yz = IrisNormalDelta;
	#else
		GBuffer.Metallic = IrisDistance;

		#if NUM_MATERIAL_OUTPUTS_GETTANGENTOUTPUT > 0
			float3 Tangent = GetTangentOutput0(MaterialParameters);
			GBuffer.CustomData.yz = UnitVectorToOctahedron( normalize(Tangent) ) * 0.5 + 0.5;
		#endif
	#endif

	#if SHADING_PATH_MOBILE
		#if MATERIAL_SHADINGMODEL_EYE_USE_CURVATURE
			GBuffer.Curvature = Metallic;
		#else
			GBuffer.Curvature = CalculateCurvature(GBuffer.WorldNormal, MaterialParameters.WorldPosition_CamRelative);
		#endif

		GBuffer.Curvature = clamp(GBuffer.Curvature, 0.001f, 1.0f);
	#endif
	}
#endif
//추가
#if MATERIAL_SHADINGMODEL_TOON
	else if (ShadingModel == SHADINGMODELID_TOON)
	{
		GBuffer.CustomData.x = saturate( GetMaterialCustomData0(MaterialParameters) );
		GBuffer.CustomData.y = saturate( GetMaterialCustomData1(MaterialParameters) );
	}
#endif

 

  • 위 작업을  완료한 후 다음 단계는 실제로 라이트 계산 및 라이트 모델을 수정해야 한다

 

The deferred light pass

  • 함수의 시작점은 DeferredLightPixelShaders.usf 파일에 있는 DeferredLightPixelMain() 함수입니다.
    • 이 함수 안에서 라이트는 모두 GetDynamicLighting() 함수에 의해 계산되며,
    • 이 함수는 GetDynamicLightingSplit() 을 호출하고,
    • 이 함수는 다시 DeferredLightingCommon.ush 파일에서 AccumulateDynamicLighting() 을 호출합니다:

 

// DeferredLightPixelShaders.usf

void DeferredLightPixelMain(
#if LIGHT_SOURCE_SHAPE > 0
	float4 InScreenPosition : TEXCOORD0,
#else
	float2 ScreenUV			: TEXCOORD0,
	float3 ScreenVector		: TEXCOORD1,
#endif

// [...]

	)
{
	const float2 PixelPos = SVPos.xy;
	OutColor = 0;

// [...]

	float SurfaceShadow = 1.0f;
		
	float4 LightAttenuation = GetLightAttenuationFromShadow(InputParams, SceneDepth);
	float4 Radiance = GetDynamicLighting(DerivedParams.TranslatedWorldPosition, DerivedParams.CameraVector, ScreenSpaceData.GBuffer, ScreenSpaceData.AmbientOcclusion, ScreenSpaceData.GBuffer.ShadingModelID, LightData, LightAttenuation, Dither, uint2(InputParams.PixelPos), SurfaceShadow);

	OutColor += Radiance;

// [...]
}

// DeferredLightingCommon.ush

float4 GetDynamicLighting(
	float3 TranslatedWorldPosition, float3 CameraVector, FGBufferData GBuffer, float AmbientOcclusion, uint ShadingModelID, 
	FDeferredLightData LightData, float4 LightAttenuation, float Dither, uint2 SVPos, 
	inout float SurfaceShadow)
{
	FDeferredLightingSplit SplitLighting = GetDynamicLightingSplit(
		TranslatedWorldPosition, CameraVector, GBuffer, AmbientOcclusion, ShadingModelID, 
		LightData, LightAttenuation, Dither, SVPos, 
		SurfaceShadow);

	return SplitLighting.SpecularLighting + SplitLighting.DiffuseLighting;
}

/** Calculates lighting for a given position, normal, etc with a fully featured lighting model designed for quality. */
FDeferredLightingSplit GetDynamicLightingSplit(
	float3 TranslatedWorldPosition, float3 CameraVector, FGBufferData GBuffer, float AmbientOcclusion, uint ShadingModelID, 
	FDeferredLightData LightData, float4 LightAttenuation, float Dither, uint2 SVPos, 
	inout float SurfaceShadow)
{
	FLightAccumulator LightAccumulator = AccumulateDynamicLighting(TranslatedWorldPosition, CameraVector, GBuffer, AmbientOcclusion, ShadingModelID, LightData, LightAttenuation, Dither, SVPos, SurfaceShadow);
	return LightAccumulator_GetResultSplit(LightAccumulator);
}

 

  •  AccumulateDynamicLighting() 함수는 각 조명과 픽셀에 대한 빛과 그림자를 계산하는 데 사용되는 함수입니다.
  • 꽤 길기 때문에 이해에 도움이 되는 중요한 부분만 설명하겠습니다.
  • 픽셀의 경우 먼저 빛 감쇠를 얻습니다.
  • 픽셀이 라이트 마스크 밖에 있으면 나머지 계산은 쓸모가 없으므로 버립니다.

 

float3 L = LightData.Direction;	// Already normalized
float3 ToLight = L;
float3 MaskedLightColor = LightData.Color;
float LightMask = 1;
if (LightData.bRadialLight)
{
	LightMask = GetLocalLightAttenuation( TranslatedWorldPosition, LightData, ToLight, L );
	MaskedLightColor *= LightMask;
}

LightAccumulator.EstimatedCost += 0.3f;		// running the PixelShader at all has a cost

BRANCH
if( LightMask > 0 )

 

  • 픽셀이 마스크 안에 있으면 그림자 항을 얻습니다.
  • 픽셀 위치가 다른 오브젝트(SurfaceShadow 멤버)가 드리운 그림자 안에 있는 경우와 같은 그림자 계산입니다:
FShadowTerms Shadow;
Shadow.SurfaceShadow = AmbientOcclusion;
Shadow.TransmissionShadow = 1;
Shadow.TransmissionThickness = 1;
Shadow.HairTransmittance.OpaqueVisibility = 1;
const float ContactShadowOpacity = GBuffer.CustomData.a;
GetShadowTerms(GBuffer.Depth, GBuffer.PrecomputedShadowFactors, GBuffer.ShadingModelID, ContactShadowOpacity, LightData, TranslatedWorldPosition, L, LightAttenuation, Dither, Shadow);
SurfaceShadow = Shadow.SurfaceShadow;

 

  • 그런 다음 중요한 함수인 IntegrateBxDF(). 
  • 이 함수에서는 셰이딩 모델에 따라 다양한 셰이딩 메서드가 실행됩니다. 
  • 이 함수는 디퓨즈, 스페큘러 및 투과 색상(서브서피스)을 계산합니다. 
  • 여기서 셀 셰이딩 계산을 수행합니다
    • IntegrateBxDF 이 함수안에 새로운 Toon 모델 추가

 

if (LightData.bRectLight)
{
	FRect Rect = GetRect( ToLight, LightData );
	const FRectTexture SourceTexture = InitRectTexture(LightData);

	#if REFERENCE_QUALITY
		Lighting = IntegrateBxDF( GBuffer, N, V, Rect, Shadow, SourceTexture, SVPos );
	#else
		Lighting = IntegrateBxDF( GBuffer, N, V, Rect, Shadow, SourceTexture);
	#endif
}
else
{
	FCapsuleLight Capsule = GetCapsule( ToLight, LightData );

	#if REFERENCE_QUALITY
		Lighting = IntegrateBxDF( GBuffer, N, V, Capsule, Shadow, SVPos );
	#else
		Lighting = IntegrateBxDF( GBuffer, N, V, Capsule, Shadow, LightData.bInverseSquared );
	#endif
}

 

  • 마지막으로 LightAccumulator_AddSplit() 함수는 조명 결과를 누적합니다(표면 그림자 및 투과 그림자도 적용). 
  • 이 함수는 여러 번 호출할 수 있습니다:

 

Lighting.Specular *= LightData.SpecularScale;
				
LightAccumulator_AddSplit( LightAccumulator, Lighting.Diffuse, Lighting.Specular, Lighting.Diffuse, LightData.Color * SurfaceShadowMultiplier, bNeedsSeparateSubsurfaceLightAccumulation );
LightAccumulator_AddSplit( LightAccumulator, Lighting.Transmission, 0.0f, Lighting.Transmission, MaskedLightColor * Shadow.TransmissionShadow, bNeedsSeparateSubsurfaceLightAccumulation );

 

  • 이제 라이트 패스가 어떻게 작동하는지 이해했습니다. 
  • 셀셰이딩 계산을 해보겠습니다.
  • ShadingModelID를 기반으로 BxDF 선택
//ShadingModels.ush
FDirectLighting IntegrateBxDF( FGBufferData GBuffer, half3 N, half3 V, half3 L, float Falloff, half NoL, FAreaLight AreaLight, FShadowTerms Shadow )
{
	switch( GBuffer.ShadingModelID )
	{
		case SHADINGMODELID_DEFAULT_LIT:
		case SHADINGMODELID_SINGLELAYERWATER:
		case SHADINGMODELID_THIN_TRANSLUCENT:
			return DefaultLitBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_SUBSURFACE:
			return SubsurfaceBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_PREINTEGRATED_SKIN:
			return PreintegratedSkinBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_CLEAR_COAT:
			return ClearCoatBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_SUBSURFACE_PROFILE:
			return SubsurfaceProfileBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_TWOSIDED_FOLIAGE:
			return TwoSidedBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_HAIR:
			return HairBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_CLOTH:
			return ClothBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_EYE:
			return EyeBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		case SHADINGMODELID_TOON: //추가
			return ToonBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
		default:
			return (FDirectLighting)0;
	}
}

 

  • ToonBxDF 정의
    • 중국 사이트 Zhihu 에서 검색하시면 다양한 Toon 관련 라이트 계산 코드들이 많이 나옵니다.
FDirectLighting ToonBxDF(FGBufferData GBuffer, half3 N, half3 V, half3 L, float Falloff, float NoL, FAreaLight AreaLight, FShadowTerms Shadow)
{
#if GBUFFER_HAS_TANGENT
	half3 X = GBuffer.WorldTangent;
	half3 Y = normalize(cross(N, X));
#else
	half3 X = 0;
	half3 Y = 0;
#endif
	
	BxDFContext Context;
	Init(Context, N, X, Y, V, L);
	SphereMaxNoH(Context, AreaLight.SphereSinAlpha, true);
	Context.NoV = saturate(abs(Context.NoV) + 1e-5);

	float SpecularOffset = 0.5;
	float SpecularRange = GBuffer.CustomData.x;

	float3 ShadowColor = 0;
	
	ShadowColor = GBuffer.DiffuseColor * ShadowColor;
	float offset = GBuffer.CustomData.y;
	float SoftScatterStrength = 0;

	offset = offset * 2 - 1;
	half3 H = normalize(V + L);
	float NoH = saturate(dot(N, H));
	NoL = (dot(N, L) + 1) / 2; // overwrite NoL to get more range out of it
	half NoLOffset = saturate(NoL + offset);
	
	FDirectLighting Lighting;
	Lighting.Diffuse = AreaLight.FalloffColor * (smoothstep(0, 1, NoLOffset) * Falloff) * Diffuse_Lambert(GBuffer.DiffuseColor) * 2.2;

	float InScatter = pow(saturate(dot(L, -V)), 12) * lerp(3, .1f, 1);
	float NormalContribution = saturate(dot(N, H));
	float BackScatter = GBuffer.GBufferAO * NormalContribution / (PI * 2);

	Lighting.Specular = ToonStep(SpecularRange, (saturate(D_GGX(SpecularOffset, NoH)))) * (AreaLight.FalloffColor * GBuffer.SpecularColor * Falloff * 8);

	float3 TransmissionSoft = AreaLight.FalloffColor * (Falloff * lerp(BackScatter, 1, InScatter)) * ShadowColor * SoftScatterStrength;
	float3 ShadowLightener = (saturate(smoothstep(0, 1, saturate(1 - NoLOffset))) * ShadowColor * 0.1);
	
	Lighting.Transmission = (ShadowLightener + TransmissionSoft) * Falloff;
	return Lighting;
}

 

  • 주로 카툰 렌더링을 위한 그라데이션 감쇠 조명을 달성하기 위해 조명 계산을 변경합니다
//DeferredLightingCommon.ush
BRANCH
	if( LightMask > 0 )
	{
		FShadowTerms Shadow;
		Shadow.SurfaceShadow = AmbientOcclusion;
		Shadow.TransmissionShadow = 1;
		Shadow.TransmissionThickness = 1;
		Shadow.HairTransmittance.OpaqueVisibility = 1;
		const float ContactShadowOpacity = GBuffer.CustomData.a;
		GetShadowTerms(GBuffer.Depth, GBuffer.PrecomputedShadowFactors, GBuffer.ShadingModelID, ContactShadowOpacity,
			LightData, TranslatedWorldPosition, L, LightAttenuation, Dither, Shadow);
		SurfaceShadow = Shadow.SurfaceShadow;

		LightAccumulator.EstimatedCost += 0.3f;		// add the cost of getting the shadow terms
		
        //여기 부분 추가
		float3 Attenuation = 1;
		BRANCH
		if (GBuffer.ShadingModelID == SHADINGMODELID_TOON)
		{
			float offset = GBuffer.CustomData.y;
			float TerminatorRange = saturate(GBuffer.Roughness - 0.5);
				
			offset = offset * 2 - 1;
				
			BRANCH
			if (offset >= 1)
			{
				Attenuation = 1;
			}
			else
			{
				float NoL = (dot(N, L) + 1) / 2;
				float NoLOffset = saturate(NoL + offset);
				float LightAttenuationOffset = saturate( Shadow.SurfaceShadow + offset);
				float ToonSurfaceShadow = smoothstep(0.5 - TerminatorRange, 0.5 + TerminatorRange, LightAttenuationOffset);

				Attenuation = smoothstep(0.5 - TerminatorRange, 0.5 + TerminatorRange, NoLOffset) * ToonSurfaceShadow;
			}
		}

		
#if SHADING_PATH_MOBILE
		const bool bNeedsSeparateSubsurfaceLightAccumulation = UseSubsurfaceProfile(GBuffer.ShadingModelID);
		
		FDirectLighting Lighting = (FDirectLighting)0;

		half NoL = max(0, dot(GBuffer.WorldNormal, L));
	#if TRANSLUCENCY_NON_DIRECTIONAL
		NoL = 1.0f;
	#endif
		Lighting = EvaluateBxDF(GBuffer, N, V, L, NoL, Shadow);

		Lighting.Specular *= LightData.SpecularScale;
		
        //LightAccumulator_AddSplit 이부분에서 위에 계산한 Attenuation를 곱해준다
		LightAccumulator_AddSplit( LightAccumulator, Lighting.Diffuse, Lighting.Specular, Lighting.Diffuse, MaskedLightColor * Shadow.SurfaceShadow * Attenuation, bNeedsSeparateSubsurfaceLightAccumulation );
		LightAccumulator_AddSplit( LightAccumulator, Lighting.Transmission, 0.0f, Lighting.Transmission, MaskedLightColor * Shadow.TransmissionShadow, bNeedsSeparateSubsurfaceLightAccumulation );
#else // SHADING_PATH_MOBILE
		BRANCH
		if( Shadow.SurfaceShadow + Shadow.TransmissionShadow > 0 )
		{
			const bool bNeedsSeparateSubsurfaceLightAccumulation = UseSubsurfaceProfile(GBuffer.ShadingModelID);

		#if NON_DIRECTIONAL_DIRECT_LIGHTING
			float Lighting;

			if( LightData.bRectLight )
			{
				FRect Rect = GetRect( ToLight, LightData );

				Lighting = IntegrateLight( Rect );
			}
			else
			{
				FCapsuleLight Capsule = GetCapsule( ToLight, LightData );

				Lighting = IntegrateLight( Capsule, LightData.bInverseSquared );
			}

			float3 LightingDiffuse = Diffuse_Lambert( GBuffer.DiffuseColor ) * Lighting;
			//추가
			LightAccumulator_AddSplit(LightAccumulator, LightingDiffuse, 0.0f, 0, MaskedLightColor * Shadow.SurfaceShadow * Attenuation, bNeedsSeparateSubsurfaceLightAccumulation);
		#else
			FDirectLighting Lighting;

			if (LightData.bRectLight)
			{
				FRect Rect = GetRect( ToLight, LightData );
				const FRectTexture SourceTexture = ConvertToRectTexture(LightData);

				#if REFERENCE_QUALITY
					Lighting = IntegrateBxDF( GBuffer, N, V, Rect, Shadow, SourceTexture, SVPos );
				#else
					Lighting = IntegrateBxDF( GBuffer, N, V, Rect, Shadow, SourceTexture);
				#endif
			}
			else
			{
				FCapsuleLight Capsule = GetCapsule( ToLight, LightData );

				#if REFERENCE_QUALITY
					Lighting = IntegrateBxDF( GBuffer, N, V, Capsule, Shadow, SVPos );
				#else
					Lighting = IntegrateBxDF( GBuffer, N, V, Capsule, Shadow, LightData.bInverseSquared );
				#endif
			}

			Lighting.Specular *= LightData.SpecularScale;
			//추가
			LightAccumulator_AddSplit( LightAccumulator, Lighting.Diffuse, Lighting.Specular, Lighting.Diffuse, MaskedLightColor * Shadow.SurfaceShadow * Attenuation, bNeedsSeparateSubsurfaceLightAccumulation );
			LightAccumulator_AddSplit( LightAccumulator, Lighting.Transmission, 0.0f, Lighting.Transmission, MaskedLightColor * Shadow.TransmissionShadow, bNeedsSeparateSubsurfaceLightAccumulation );

			LightAccumulator.EstimatedCost += 0.4f;		// add the cost of the lighting computations (should sum up to 1 form one light)
		#endif
		}
#endif // SHADING_PATH_MOBILE
	}

 

  • ShadingModel과 관련된 사용자 정의 기능을 저장하기 위해 새 파일이 생성됩니다.
//ToonShadingCommon.ush
float3 ToonStep(float feather, float halfLambert, float threshold = 0.5f)
{
	return smoothstep(threshold - feather, threshold + feather, halfLambert);
}

 

  • 머트리얼에서 선을 연결하면 기본 카툰 렌더링 효과를 볼 수 있다.
  • 원하는 카툰 쉐이딩 효과를 만들려면 쉐이딩 코드를 수정해야 합니다.
  • 라이트 계산 코드 역시 Zhihu의 많은 글을 참고하고 있다. Shading Model이라는 키워드로 검색하시면 많이 나옵니다.

 

 

  • 참고자료
    • 아래 자료를 참고하여 구글 번역 및 정리하였습니다.

https://eukky.github.io/GameDev/UnrealEngine/CustomShadingModel.html

 

UE5中的卡通渲染——自定义Shading Model | META TECH

 

eukky.github.io

https://dev.epicgames.com/community/learning/tutorials/2R5x/unreal-engine-new-shading-models-and-changing-the-gbuffer

 

New shading models and changing the GBuffer | Community tutorial

Implementing a Celshading model directly into UE5.1 source. This celshading use a linear color curve atlas to drive all the values. Learn how to set you...

dev.epicgames.com

https://zhuanlan.zhihu.com/p/404857208

 

UE5自定义着色模型 Unreal Engine 5 custom Shading Model

UE5相信大家已经迫不及待地用起来了,而custom Shading Model又是很常见的需求,也是认识UE5渲染管线非常好的途径。在这之前我尚未接触UE源码,网上暂时也没有什么针对性教程,所以今天就写一篇

zhuanlan.zhihu.com

https://github.com/mljuw/UEToonStylized

 

GitHub - mljuw/UEToonStylized

Contribute to mljuw/UEToonStylized development by creating an account on GitHub.

github.com

https://zhuanlan.zhihu.com/p/542384881

 

Yivanlee's UE5 Toon Rendering Technical Documents

随着卡通渲染的需求越来越多,我们需要一套高品质的UE5卡通渲染技术方案,为此我对虚幻5进行了管线定制来实现高品质的卡通渲染需求,适用于游戏,影视,虚拟偶像等多种场合的需求。 推

zhuanlan.zhihu.com

 

728x90
반응형