UE5 Substrate Thin Film 源码拆析

源码版本UE5.8

一、MaterialExpressionSubstrate.h(第1657行)

复制代码
UCLASS(MinimalAPI, collapsecategories, hidecategories = Object, DisplayName = "Substrate Thin-Film")
class UMaterialExpressionSubstrateThinFilm : public UMaterialExpressionSubstrateUtilityBase
{
	GENERATED_UCLASS_BODY()

	/**
	 * The normal of the surface to consider. This input respects the normal space setup on the root node (tangent or world)
	 */
	UPROPERTY()
	FExpressionInput Normal;

	/**
	 * Defines the color and brightness of the specular highlight where the surface is facing the camera. Each specular contribution will fade to black as F0 drops below 0.02. (type = float3, unit = unitless, defaults to plastic 0.04)
	 */
	UPROPERTY()
	FExpressionInput F0;

	/**
	 * Defines the color of the specular highlight where the surface normal is 90 degrees from the view direction. Only the hue and saturation are preserved, the brightness is fixed at 1.0. Fades to black as F0 drops below 0.02. (type = float3, unit = unitless, defaults to 1.0f)
	 */
	UPROPERTY()
	FExpressionInput F90;
	
	/**
	 * Controls the thickness of the thin film layer coating the current slab. 0 means disabled and 1 means a coating layer of 10 micrometer. (type = float, unitless, default = 0)
	 */
	UPROPERTY()
	FExpressionInput Thickness;

	/**
	 * Thin film IOR
	 */
	UPROPERTY()
	FExpressionInput IOR;

	//~ Begin UMaterialExpression Interface
#if WITH_EDITOR
	virtual int32 Compile(class FMaterialCompiler* Compiler, int32 OutputIndex) override;
	virtual void Build(MIR::FEmitter& Emitter) override;
	virtual void GetCaption(TArray<FString>& OutCaptions) const override;
	virtual EMaterialValueType GetOutputValueType(int32 OutputIndex) override;
	virtual EMaterialValueType GetInputValueType(int32 InputIndex) override;
	virtual void GetConnectorToolTip(int32 InputIndex, int32 OutputIndex, TArray<FString>& OutToolTip) override;
	virtual void GetExpressionToolTip(TArray<FString>& OutToolTip) override;
#endif
	//~ End UMaterialExpression Interface
};

声明变量以及编译入口,这里的Compile是传统的编译路线

Build是MIR的新式编译路线


二、MaterialExpressionSubstrate.cpp(第4376行)

复制代码
UMaterialExpressionSubstrateThinFilm::UMaterialExpressionSubstrateThinFilm(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
{
	struct FConstructorStatics
	{
		FText NAME_Substrate;
		FConstructorStatics() : NAME_Substrate(LOCTEXT("Substrate Helpers", "Substrate Helpers")) { }
	};
	static FConstructorStatics ConstructorStatics;
#if WITH_EDITORONLY_DATA
	MenuCategories.Add(ConstructorStatics.NAME_Substrate);
#endif

	bShowOutputNameOnPin = true;

	Outputs.Reset();
	Outputs.Add(FExpressionOutput(TEXT("Specular Color")));
	Outputs.Add(FExpressionOutput(TEXT("Edge Specular Color")));
}

构造函数定义了两个输出:

复制代码
Outputs.Add(FExpressionOutput(TEXT("Specular Color")));
Outputs.Add(FExpressionOutput(TEXT("Edge Specular Color")));

对应:

复制代码
输出 0 = 新的 F0
输出 1 = 新的 F90

Thin Film 节点没有输出一个 Substrate BSDF,也没有输出一个 Closure。它只输出两个 float3,也就是经过薄膜干涉修正的 F0 和 F90


三、MaterialExpressionSubstrate.cpp(第4396行)

复制代码
#if WITH_EDITOR
int32 UMaterialExpressionSubstrateThinFilm::Compile(class FMaterialCompiler* Compiler, int32 OutputIndex)
{
	int32 NormalCodeChunk = CompileWithDefaultNormalWS(Compiler, Normal);

	int32 F0CodeChunk = F0.GetTracedInput().Expression ? F0.Compile(Compiler) : Compiler->Constant3(0.04f, 0.04f, 0.04f);
	int32 F90CodeChunk = F90.GetTracedInput().Expression ? F90.Compile(Compiler) : Compiler->Constant3(1.0f, 1.0f, 1.0f);

	int32 ThicknessCodeChunk = Thickness.GetTracedInput().Expression ? Thickness.Compile(Compiler) : Compiler->Constant(1.0f);
	int32 IORCodeChunk = IOR.GetTracedInput().Expression ? IOR.Compile(Compiler) : Compiler->Constant(1.44f);
}

这里给Substrate Thin Film输入给到指定默认值


四、传统编译和MIR编译 HLSLMaterialTranslator.cpp(第14027行) MaterialExpressionsToMIR.cpp(第7952行)

复制代码
int32 FHLSLMaterialTranslator::SubstrateThinFilm(int32 NormalCodeChunk, int32 SpecularColorCodeChunk, int32 EdgeSpecularColorCodeChunk, int32 ThicknessCodeChunk, int32 IORCodeChunk, int32 OutputIndex)
{
	if (NormalCodeChunk == INDEX_NONE || SpecularColorCodeChunk == INDEX_NONE || EdgeSpecularColorCodeChunk == INDEX_NONE
		|| ThicknessCodeChunk == INDEX_NONE || IORCodeChunk == INDEX_NONE)
	{
		Errorf(TEXT("Substrate error: one of SubstrateThinFilm node input is invalid (asset: %s).\r\n"), *Material->GetDebugName(), *Material->GetAssetPath().ToString());
		return INDEX_NONE;
	}
	if (OutputIndex < 0 || OutputIndex > 1)
	{
		Errorf(TEXT("Substrate error: SubstrateThinFilm output index is invalid (asset: %s).\r\n"), *Material->GetDebugName(), *Material->GetAssetPath().ToString());
		return INDEX_NONE;
	}
	const int32 CameraVectorCode = CameraVector();
	if (CameraVectorCode == INDEX_NONE)
	{
		return INDEX_NONE;
	}
	return AddCodeChunk(
		MCT_Float3, TEXT("SubstrateGetThinFilmF0F90(%s, %s, %s, %s, %s)%s"),
		*GetParameterCode(Dot(NormalCodeChunk, CameraVectorCode)),
		*GetParameterCode(SpecularColorCodeChunk),
		*GetParameterCode(EdgeSpecularColorCodeChunk),
		*GetParameterCode(ThicknessCodeChunk),
		*GetParameterCode(IORCodeChunk),
		OutputIndex == 0 ? TEXT(".F0") : TEXT(".F90")
	);
}

void UMaterialExpressionSubstrateThinFilm::Build(MIR::FEmitter& Em)
{
	UMaterial* DummyMaterial = nullptr;
	bool bConvertToRequestedSpace = false;
	FValueRef NormalArg		= CompileWithDefaultNormalWS(Em, DummyMaterial, Normal, bConvertToRequestedSpace);

	FValueRef F0Arg			= F0.GetTracedInput().Expression ?			Em.TryInput(&F0)		: Em.ConstantFloat(0.04f);
	FValueRef F90Arg		= F90.GetTracedInput().Expression ?			Em.TryInput(&F90)		: Em.ConstantFloat(1.0f);
	FValueRef ThicknessArg	= Thickness.GetTracedInput().Expression ?	Em.TryInput(&Thickness)	: Em.ConstantFloat(1.0f);
	FValueRef IORArg		= IOR.GetTracedInput().Expression ?			Em.TryInput(&IOR)		: Em.ConstantFloat(1.44f);

	FValueRef CameraVector = Em.Extern<MIR::FExternFromMaterialDecl>(NAME_CameraVector);

	MIR::FValueRef OutF0Arg = Em.Extern<MIR::FExternSimpleHLSL>({ MIR::EExternSimpleType::Float3, TEXT("SubstrateGetThinFilmF0F90($0, $1, $2, $3, $4).F0") },
		Em.Dot(NormalArg, CameraVector),
		F0Arg,
		F90Arg,
		ThicknessArg,
		IORArg);

	// Calling SubstrateGetThinFilmF0F90 a second time. SUBSTRATE_TODO: create a structure to pull the data from the first call instead
	MIR::FValueRef OutF90Arg = Em.Extern<MIR::FExternSimpleHLSL>({ MIR::EExternSimpleType::Float3, TEXT("SubstrateGetThinFilmF0F90($0, $1, $2, $3, $4).F90") },
		Em.Dot(NormalArg, CameraVector),
		F0Arg,
		F90Arg,
		ThicknessArg,
		IORArg);

	Em.Output(0, OutF0Arg);
	Em.Output(1, OutF90Arg);
}

传统编译路线和 MIR 的主要区别不是 Thin Film 算法不同,而是:

它们用两种不同的内部表示,把材质节点图转换成 HLSL。

最终通常都会生成:

复制代码
SubstrateGetThinFilmF0F90(...)

传统路线可以理解为:

复制代码
递归编译上游节点
    ↓
每个表达式得到一个 CodeChunk 编号
    ↓
从 CodeChunk 取出对应 HLSL 文本
    ↓
使用格式字符串拼成更大的 HLSL 表达式

MIR路线:

Epic 增加 MIR 的根本原因,是旧材质编译器过早地把材质语义变成了 HLSL CodeChunk,导致后面很难再从整体上理解和优化材质图。

MIR 不是为了让同一个材质再编译一次,也不是为了给 Thin Film 增加一种新算法。它的目标是逐步替代旧材质编译器内部架构:

复制代码
旧架构:
材质节点 → CodeChunk/HLSL 字符串 → DXC

新架构:
材质节点 → 有类型的 MIR 运算图 → 分析与优化 → HLSL/Preshader → DXC

核心问题可以概括成一句话:

旧编译器太早丢失了"这段代码在表达什么",只剩下"这段 HLSL 字符串长什么样"。

旧路线的核心问题

还是以 Thin Film 为例:

复制代码
return AddCodeChunk(
    MCT_Float3,
    TEXT("SubstrateGetThinFilmF0F90(%s, %s, %s, %s, %s)%s"),
    ...
);

一旦形成这种表达式:

复制代码
SubstrateGetThinFilmF0F90(Local12, Local5, Local6, Local8, Local9).F0

编译器后续看到的主要是一段 HLSL 表达式和 CodeChunk 引用。

它很难从通用的编译器角度判断:

  • 这五个输入分别是什么语义;
  • 这是一个有两个输出的计算;
  • F0 与 F90 来自同一次 Thin Film 求值;
  • 哪些输入是常量;
  • 哪些输入是 Uniform;
  • 计算应该发生在 Vertex、Pixel 还是 Compute Stage;
  • 哪些分支最终没有连接到任何材质输出;
  • 两段表达式是否代表同一个计算;
  • 哪些运算可以转移到 CPU Preshader;
  • 如果未来不输出 HLSL,还能不能输出其他后端。

旧编译器也做了很多缓存和优化,不能说它完全无法处理这些问题。但这些能力往往分散在 Translator、CodeChunk、Uniform Expression、Preshader 和各节点的特殊逻辑里,继续扩展和维护会越来越困难。

MIR 解决的第一个问题:保留完整运算关系

MIR 不立即把表达式压成字符串,而是保存为运算图:

复制代码
Normal ─────────┐
                ├─ Dot ─ NoV ─────────┐
CameraVector ───┘                      │
F0 ────────────────────────────────────┤
F90 ───────────────────────────────────┤ ThinFilm
Thickness ─────────────────────────────┤
IOR ───────────────────────────────────┘

五、看Substrate 如何调用薄膜核心(Substrate.ush第3579行)

复制代码
void SubstrateGetThinFilmF0F90(float NoV, float ThinFilmNormalizedThickness, float ThinFilmIOR, inout float3 OutF0, inout float3 OutF90)
{
	const float3 F0 = OutF0;
	const float3 F90 = OutF90;

	const float NoL = NoV;
	const float VoH = NoV;
	float3 RThinFilm = F_ThinFilm(NoV, NoL, VoH, F0, F90, ThinFilmIOR, ThinFilmNormalizedThickness);

	// Compute a F0 which match evaluation Thin-Film reflectance for the current view angle using Schlick's Fresnel
	const float FReference = min(0.99f, F_Schlick(0, 1.0f, NoV).x);
	RThinFilm = max(RThinFilm, FReference);

	float3 F0_Thin = (RThinFilm - F90 * FReference) / (1 - FReference);

	// Ensure the generated F0 doesn't cause micro-oclusion
	F0_Thin = max(GetF0MicroOcclusionThreshold(), F0_Thin);

	// If micro-occlusion was set, re-apply it.		
	const float3 MicroOcclusion = F0RGBToMicroOcclusion(F0);
	F0_Thin *= MicroOcclusion;

	OutF0 = F0_Thin;
	OutF90 = max(F0_Thin, F90);
}

UE 假设正在计算镜面高光中心,也就是理想反射方向。在这个位置,三个值恰好相等:

复制代码
NoV = NoL = VoH

我前面把它说成单纯的"统一角度控制量"不够准确。它有明确的几何依据。

看这张图

复制代码
                     N
                     ↑
                     │
              H = N  │
                     │
        L ↖          │          ↗ V
           \         │         /
            \  θ     │     θ  /
             \       │       /
              \      │      /
───────────────●─────┴──────────────── 表面

这里:

  • V 是表面指向相机的方向。
  • L 是表面指向灯光的方向。
  • L 和 V 关于法线 N 对称。
  • 这正是理想镜面反射、高光中心的几何关系。
  • 此时半角向量 H 正好等于表面法线 N。

为什么 Thin Film 要选择"高光中心"计算

薄膜干涉主要表现为镜面反射颜色,例如:

  • 肥皂泡彩色反光
  • 油膜彩虹色
  • 镀膜玻璃
  • 氧化金属表面
  • 珍珠、甲虫壳一类视角色彩

这些效果最明显的区域通常就是镜面反射区域。因此 UE 选择一个最有代表性的情况:

复制代码
假设灯光正好位于当前观察方向的理想反射位置

(ThinFilmBSDF.ush第189行)

复制代码
float3 F_ThinFilm(float NoV, float NoL, float VoH, float3 F0, float3 F90, float ThinFilmIOR, float ThinFilmTickness)
{
	// Thin film is limited (for now) to the top most layer (i.e., the upper layer need to be air)
	//					|
	//	  n1=1 / k1=0	| Air
	// ----------------- 
	//		n2 / k2=0	| Thin film
	// ----------------- 
	//		n3 / k3		| Regular material
	//					|	
	
	float Dinc = ThinFilmTickness * 10; // ThinFilmTickness is in micrometer. Range is 0 - 10um i.e, 0 - 10 000nm

	// Force eta2 -> 1.0 when Dinc -> 0.0
	const float eta2 = lerp(1.0, ThinFilmIOR, smoothstep(0.0, 0.03, Dinc));

	// Compute dot products
	const bool bIsValid = NoL > 0 && NoV > 0;

	float3 I = 0;
	if (bIsValid)
	{
		float cosTheta1 = VoH;
		float cosTheta2 = sqrt(1.0 - Pow2(1.0 / eta2) * (1 - Pow2(cosTheta1)));

		// First interface is dieletric so it is achromatic
		float R12 = F_Schlick(DielectricIorToF0(ThinFilmIOR), VoH).x;
		float phi12 = 0;
		float R21 = R12;
		float T121 = 1 - R12;
		float phi21 = PI - phi12;

		// Second interface is either conductor or dieletric, so can be chromatic. 
		// Ideally we should recompute F0 (R23) by taking into account the thin layer IOR
		float3 R23 = F0;
		float phi23 = 0;

		// Phase shift
		float OPD = Dinc * cosTheta2;
		float phi2 = phi21 + phi23;

		// Compound terms
		float3 R123 = R12 * R23;
		float3 r123 = sqrt(R123);
		float3 Rs = Pow2(T121) * R23 / (1 - R123);

		// Reflectance term for m=0 (DC term amplitude)
		float3 C0 = R12 + Rs;
		float3 S0 = ThinFilmEvalSensitivity(0.0, 0.0);
		I += C0 * S0;

		// Reflectance term for m>0 (pairs of diracs)
		// Simplified based as we don't take into account polarization
		float3 Cm = Rs - T121;
		for (int m = 1; m <= 3; ++m)
		{
			Cm *= r123;
			I += Cm * 2.0 * ThinFilmEvalSensitivity(m * OPD, m * phi2);
		}

		// Convert back to RGB reflectance
		// XYZ to CIE 1931 RGB color space (using neutral E illuminant)
		const float3x3 ThinFilm_XYZ_TO_RGB = float3x3(2.3706743, -0.5138850, 0.0052982, -0.9000405, 1.4253036, -0.0146949, -0.4706338, 0.0885814, 1.0093968);
		I = saturate(mul(I, ThinFilm_XYZ_TO_RGB));
	}
	return I;
}

把归一化厚度转换为微米

复制代码
float Dinc = ThinFilmTickness * 10;

例如:

复制代码
Thickness = 0.05
Dinc      = 0.5 μm
          = 500 nm

500 nm 已经和可见光波长处于相同数量级,所以能够产生明显的彩色干涉。

厚度接近零时,让薄膜逐渐消失

复制代码
const float eta2 = lerp(
    1.0,
    ThinFilmIOR,
    smoothstep(0.0, 0.03, Dinc));

先了解一个前提知识:Snell:

已知这个信息,那么久可以看懂cosTheta2是如何计算出来的了:

那么R12 理解成空气/薄膜第一个界面基于F0的菲涅尔遮罩

复制代码
float R21 = R12;
float T121 = 1 - R12;

介质1:空气

        ↑ R21:光从薄膜内部撞向上界面
────────────── 界面 1/2
        ↓ R12:光从空气撞向薄膜

介质2:薄膜


────────────── 界面 2/3
        ↓ R23:光从薄膜撞向基底

介质3:基底

T121画出来是:

复制代码
                 R12 = 4%
                   ↗
入射光 100% ─────●──────── 空气
                  \
───────────────────\────── 空气/薄膜界面
                    ↓
                 T121 = 96%
                    ↓
                   薄膜

其实就是透射薄膜里面的能量百分比

R23 = F0 是什么意思

F_ThinFilm() 接收的 F0 是基底材质原本的正视反射颜色:

复制代码
float3 F_ThinFilm(
    ...,
    float3 F0,
    float3 F90,
    ...
);

phi23 = 0 是什么意思

复制代码
float phi23 = 0;

phi23 表示:

光从薄膜撞到基底并发生反射时,附加了多少反射相位。

复制代码
介质2:薄膜

       ↓ 入射
───────●─────── 界面23
       ↗ 反射
       R23
       phi23

介质3:基底

真实情况下:

  • 介电质基底可能产生 0 或 π 相位变化。
  • 金属基底可能产生更复杂、与波长有关的相位。
  • S/P 偏振的相位也可能不同。

但是简化实现直接设置:

复制代码
phi23 = 0;

也就是:

忽略基底界面复杂的 Fresnel 相位,把它当作没有额外相位偏移。

完整比较到图中远处的 C₁ 截面时:

复制代码
A:O → P → S₀ → S₁
B:O → P → Q → R → R₁

因此,保留 PS₀ 的原因不是它代表 A 的完整路径,而是:

PS₀ 是 A 路径中与 B 不同、无法被共同部分抵消的那一段。

可以把完整路径拆成:

复制代码
共同入射段 + 各自不同段 + 共同出射增量

做差后:

复制代码
共同入射段:抵消
共同出射增量:抵消
只剩各自不同段

所以推导里最终只写:

复制代码
A 独有:P→S₀
B 独有:P→Q→R

并不代表 A 只传播到 S₀,而是因为其他部分在 B−A 中已经全部抵消了。

OPD = Dinc * cosTheta2 本身没有问题;UE 的特殊之处在于,它把节点的 Thickness 缩放后直接当作 Dinc,没有严格计算 Dinc = 2η₂d。

float phi2 = phi21 + phi23;

这行是在计算:光在薄膜内部每多完成一次往返时,两个界面反射共同造成的相位跳变。

这里的 phi 不是几何角度,而是光波波峰、波谷的相位。

复制代码
             上方介质 n₁
──────────────────────── 上界面
         ↑
         │ 光从薄膜内部撞击上界面
         │ 反射相位变化:phi21
         ↓
             薄膜 n₂
         ↓
         │ 光撞击下界面
         │ 反射相位变化:phi23
         ↑
──────────────────────── 下界面
             基底 n₃

光在薄膜中多走一个完整往返,会经历:

  1. 上界面内部反射一次:增加 phi21
  2. 下界面反射一次:增加 phi23

相位是可以直接相加的,所以:

复制代码
float phi2 = phi21 + phi23;

它表示:

复制代码
float3 C0 = R12 + Rs;
float3 S0 = ThinFilmEvalSensitivity(0.0, 0.0);
I += C0 * S0;

后面的代码:

复制代码
float3 Cm = Rs - T121;

for (int m = 1; m <= 3; ++m)
{
    Cm *= r123;
    I += Cm * 2.0 *
        ThinFilmEvalSensitivity(
            m * OPD,
            m * phi2
        );
}

就是在加入第 1、2、3 阶的相位干涉项。

因此可以记成:

复制代码
C0:所有光线自身的能量,不包含互相干涉
S0:零频率下的眼睛颜色响应
C0 × S0:薄膜反射的基础亮度/基础颜色
m > 0:在基础亮度上增加彩色干涉变化

I 在这个位置还是 XYZ 感光空间中的累积结果,后面经过:

复制代码
I = saturate(mul(I, ThinFilm_XYZ_TO_RGB));

才转换成最终使用的 RGB 薄膜反射颜色。

复制代码
float3 Cm = Rs - T121;
for (int m = 1; m <= 3; ++m)
{
	Cm *= r123;
	I += Cm * 2.0 * ThinFilmEvalSensitivity(m * OPD, m * phi2);
}


复制代码
void SubstrateGetThinFilmF0F90(float NoV, float ThinFilmNormalizedThickness, float ThinFilmIOR, inout float3 OutF0, inout float3 OutF90)
{
	const float3 F0 = OutF0;
	const float3 F90 = OutF90;

	const float NoL = NoV;
	const float VoH = NoV;
	float3 RThinFilm = F_ThinFilm(NoV, NoL, VoH, F0, F90, ThinFilmIOR, ThinFilmNormalizedThickness);

	// Compute a F0 which match evaluation Thin-Film reflectance for the current view angle using Schlick's Fresnel
	const float FReference = min(0.99f, F_Schlick(0, 1.0f, NoV).x);
	RThinFilm = max(RThinFilm, FReference);

	float3 F0_Thin = (RThinFilm - F90 * FReference) / (1 - FReference);

	// Ensure the generated F0 doesn't cause micro-oclusion
	F0_Thin = max(GetF0MicroOcclusionThreshold(), F0_Thin);

	// If micro-occlusion was set, re-apply it.		
	const float3 MicroOcclusion = F0RGBToMicroOcclusion(F0);
	F0_Thin *= MicroOcclusion;

	OutF0 = F0_Thin;
	OutF90 = max(F0_Thin, F90);
}

看了那么多,回到这里F_ThinFilm函数进行了薄膜干涉的颜色信息的计算

完整流程是:

复制代码
原始 F0/F90
      ↓
F_ThinFilm(F0, F90, IOR, Thickness)
      ↓
RThinFilm
      ↓
反解 Schlick
      ↓
新的等效 F0_Thin
      ↓
OutF0 = F0_Thin

六、Substrate 的 Thin Film 包装函数(Substrate.ush第3610行)

复制代码
FSubstrateThinFilmOutput SubstrateGetThinFilmF0F90(float NoV, float3 F0, float3 F90, float ThinFilmNormalizedThickness, float ThinFilmIOR)
{
	FSubstrateThinFilmOutput Output;
	Output.F0 = F0;
	Output.F90 = F90;
	SubstrateGetThinFilmF0F90(NoV, ThinFilmNormalizedThickness, ThinFilmIOR, Output.F0, Output.F90);
	return Output;
}

所以这里最后也看得懂了,上层已经讲清楚了

相关推荐
keyipatience1 小时前
JavaScript学习(1)书写位置,变量、数据类型、运算符
开发语言·前端·javascript·css·学习
栈帧之外2 小时前
【C++】STL之set
开发语言·c++
catchadmin3 小时前
免费可商用 PHP 管理后台 CatchAdmin V5.4.1 发布,新增 MCP 支持
开发语言·php
jimy14 小时前
c++的 Type Traits (二、更详细版本)
开发语言·c++
狗凯之家源码网4 小时前
纯 PHP+MySQL 应用市场系统源码实测!
开发语言·mysql·php
dishugj4 小时前
系统架构风险点、敏感点和权衡点的概念和区分
java·开发语言
geovindu5 小时前
rust:Memento Pattern(续)
开发语言·后端·设计模式·rust·行为模式·备忘录模
马优晨5 小时前
JAVA 中 VO 实体类是什么意思,为什么要做这个
java·开发语言·状态模式·java vo·java vo是什么
Aurorar0rua5 小时前
CS50 x 2024 Notes Python - 01
开发语言·python·学习方法