BPVM 小食包 #7 - 节点处理器:翻译小队 | ebp BPVM 小食包 #7 - 节点处理器:翻译小队 | ebp BPVM 小食包 #7 - 节点处理器:翻译小队 | ebp
文章

BPVM 小食包 #7 - 节点处理器:翻译小队

你的蓝图中的每个节点都需要一个翻译器。认识节点处理器——将你的可视节点转换为可执行代码的无名英雄。

BPVM 小食包 #7 - 节点处理器:翻译小队

本文内容基于Unreal Engine 5.6.0

BPVM 小食包 - 快速蓝图知识投放蓝图到字节码系列的一部分。

翻译问题

你拖动一个”Select”节点到你的蓝图中。你连接一些引脚。你点击编译。

但等等——那个可视节点如何成为实际的可执行代码?

Select node in Blueprint editor

进入节点处理器

每个节点类型都有一个专用翻译器叫做节点处理器 (Node Handler):

1
2
3
4
5
// 对于每个 UK2Node 类型...
UK2Node_Select    FKCHandler_Select
UK2Node_CallFunction    FKCHandler_CallFunction
UK2Node_VariableGet    FKCHandler_VariableGet
// ... 还有数百个!

把它们想象成联合国的专业翻译:

  • 每个处理器讲一种”节点语言”
  • 它们都翻译成相同的”字节码语言”
  • 没有它们,你的节点只是漂亮的图片!

处理器模式

每个处理器遵循相同的模式:

1
2
3
4
5
6
7
8
9
class FKCHandler_Select : public FNodeHandlingFunctor
{
public:
    // 步骤 1: "我需要什么数据?"
    virtual void RegisterNets(FKismetFunctionContext& Context, UEdGraphNode* Node);

    // 步骤 2: "我如何翻译这个?"
    virtual void Compile(FKismetFunctionContext& Context, UEdGraphNode* Node);
};

两个工作,清晰的关注点分离!

RegisterNets: 设置阶段

在编译之前,处理器需要注册它们的数据需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void FKCHandler_Select::RegisterNets(Context, Node)
{
    // "我需要这些引脚的存储!"

    // 注册索引引脚
    FBPTerminal* IndexTerm = Context.CreateLocalTerminal();
    Context.NetMap.Add(IndexPin, IndexTerm);

    // 注册每个选项引脚
    for (UEdGraphPin* Pin : OptionPins) {
        FBPTerminal* Term = Context.CreateLocalTerminal();
        Context.NetMap.Add(Pin, Term);
    }

    // 注册输出
    FBPTerminal* OutputTerm = Context.CreateLocalTerminal();
    Context.NetMap.Add(OutputPin, OutputTerm);
}

这就像在使用之前声明变量——首先预留内存!

Compile: 翻译阶段

现在实际的翻译发生了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void FKCHandler_Select::Compile(Context, Node)
{
    // 创建字节码语句
    FBlueprintCompiledStatement* Statement = new FBlueprintCompiledStatement();
    Statement->Type = KCST_SwitchValue;  // "这是一个开关操作"

    // 获取我们注册的终端
    FBPTerminal* IndexTerm = Context.NetMap.FindRef(IndexPin);
    FBPTerminal* OutputTerm = Context.NetMap.FindRef(OutputPin);

    // 构建开关逻辑
    Statement->LHS = OutputTerm;  // 存储结果的地方
    Statement->RHS.Add(IndexTerm);  // 要开关的内容

    // 添加每个 case
    for (int32 i = 0; i < Options.Num(); i++) {
        Statement->RHS.Add(OptionTerms[i]);
    }
}

真实例子: Select 节点

让我们看看 Select 节点如何被翻译:

你看到的:

1
2
3
4
5
Index: 2
Option 0: "Hello"
Option 1: "World"
Option 2: "!"      <-- 被选中!
Output: "!"

RegisterNets 做的:

1
2
3
4
5
6
// 预留内存槽
Terminal_0 = Index (integer)
Terminal_1 = Option0 (string)
Terminal_2 = Option1 (string)
Terminal_3 = Option2 (string)
Terminal_4 = Output (string)

Compile 创建的:

1
2
3
4
5
6
7
8
Statement: KCST_SwitchValue
LHS: Terminal_4 (输出)
RHS: [
    Terminal_0,  // 索引
    Terminal_1,  // Case 0
    Terminal_2,  // Case 1
    Terminal_3   // Case 2
]

处理器注册表

编译器维护一个处理器的巨大映射:

1
2
3
4
5
// 在编译器初始化期间
NodeHandlers.Add(UK2Node_Select::StaticClass(), new FKCHandler_Select());
NodeHandlers.Add(UK2Node_CallFunction::StaticClass(), new FKCHandler_CallFunction());
NodeHandlers.Add(UK2Node_VariableGet::StaticClass(), new FKCHandler_VariableGet());
// ... 还有数百个

编译你的图表时:

1
2
3
4
5
6
7
8
9
for (UEdGraphNode* Node : Graph->Nodes) {
    // 找到正确的翻译器
    FNodeHandlingFunctor* Handler = NodeHandlers.FindRef(Node->GetClass());

    if (Handler) {
        Handler->RegisterNets(Context, Node);  // 设置
        Handler->Compile(Context, Node);        // 翻译
    }
}

为什么两个阶段?

为什么不直接编译?

编译器需要在生成代码之前了解所有变量:

1
2
3
4
5
6
7
8
9
// 坏的:边走边编译
CompileNode(A);  // 创建变量 X
CompileNode(B);  // 需要变量 X... 它存在吗?

// 好的:两个阶段
RegisterNets(A);  // 声明变量 X
RegisterNets(B);  // 声明变量 Y
Compile(A);       // 使用变量 X(保证存在)
Compile(B);       // 使用变量 X 和 Y(都存在!)

特殊处理器能力

一些处理器有特殊能力:

1
2
3
4
5
6
7
8
9
10
11
12
class FKCHandler_CallFunction : public FNodeHandlingFunctor
{
    // 特殊能力:可以优化某些调用!
    virtual void Transform(FKismetFunctionContext& Context, UEdGraphNode* Node) {
        // 如果可能,将 Print(String) 转换为快速路径
    }

    // 特殊能力:为签名提前运行!
    virtual bool RequiresRegisterNetsBeforeScheduling() {
        return true;  // 函数入口/出口节点需要这个
    }
};

语句输出

处理器产生中间语句(还不是字节码!):

1
2
3
4
5
6
7
8
9
10
11
12
// 处理器产生这个:
Statement {
    Type: KCST_CallFunction
    Function: "PrintString"
    Parameters: ["Hello World"]
}

// 后端稍后转换为字节码:
0x44 (EX_CallFunc)
0x08 (Function ID)
"Hello World"
0x53 (EX_Return)

这是一个两级火箭 - 处理器让你进入轨道,后端让你到达月球!

快速要点

  • 每个节点类型都有一个节点处理器(它的个人翻译器)
  • RegisterNets: “我需要这些变量”(设置阶段)
  • Compile: “这是如何执行我”(翻译阶段)
  • 处理器产生语句,不是字节码(那个稍后来)
  • 两个阶段确保所有变量在使用前存在
  • 这是策略模式在行动——每个节点类型一个处理器!

你的节点活起来了

下次你拖动一个节点到你的蓝图时,记住:

  • 那个节点有一个专用的处理器等待翻译它
  • RegisterNets 首先运行以设置工作空间
  • Compile 其次运行以生成逻辑
  • 没有处理器,你的节点只会是漂亮的图片!

想要更多细节?

有关完整的处理器深入:

下一份小食:清理和净化的魔法!


🍿 BPVM 小食包系列

本文由作者按照 CC BY 4.0 进行授权