three.js NodeObjectLoader 深度解析:用 JSON 场景格式加载 TSL 节点材质对象
NodeObjectLoader 是 three.js 中专门用于加载「以节点材质(Node Material)描述」的 3D 对象的加载器,继承自 ObjectLoader。在采用 TSL(Three Shading Language)工作流的项目中,对象、几何体与材质可以被序列化为 three.js 标准 JSON Object/Scene 格式;其中普通材质由 MaterialLoader 处理,而任何包含 TSL 节点的 NodeMaterial 则必须经由 NodeObjectLoader 还原为可编程着色器节点树。读完本文,你将理解该加载器的完整 API(构造、属性、parse/parseAsync/parseMaterials/parseNodes/setNodes/setNodeMaterials)、JSON 中节点与材质的序列化结构,以及它如何与 NodeLoader、NodeMaterialLoader 协作完成一次完整的反序列化。
定位与继承体系
按照官方 API 文档(docs/pages/NodeObjectLoader.html.md)的定义:
Inheritance: Loader → ObjectLoader → NodeObjectLoader
A special type of object loader for loading 3D objects using node materials.
从源码结构看,NodeObjectLoader 的类声明为 class NodeObjectLoader extends ObjectLoader,构造函数接收一个可选的 LoadingManager 引用并转发给父类:
import { NodeObjectLoader } from 'three/webgpu';
// 基本用法:加载包含节点材质的 JSON 场景
const loader = new NodeObjectLoader();
const object = await loader.loadAsync( 'models/scene.json' );
scene.add( object );
// 或解析一个已获取的 JSON 对象
const json = await ( await fetch( 'models/scene.json' ) ).json();
const object = await loader.parseAsync( json );
loadAsync 等方法继承自 Loader / ObjectLoader,真正体现节点语义的部分在于下面介绍的属性与 parse 系列方法。该加载器通过 Three.WebGPU.Nodes.js 与 Three.WebGPU.js 统一导出(export { default as NodeObjectLoader } from './loaders/nodes/NodeObjectLoader.js'),因此使用 WebGPU 入口引入 three/webgpu 时即可直接使用。
核心属性:节点类型字典
文档定义了两个属性,其默认值均为空对象(NodeObjectLoader.js#L23-L35):
| 属性 | 类型 | 含义 |
|---|---|---|
.nodes |
Object.<string, Node.constructor> |
节点类型字典,键为节点类名(type),值为节点类构造器 |
.nodeMaterials |
Object.<string, NodeMaterial.constructor> |
节点材质类型字典,键为材质类名,值为材质类构造器 |
这两个字典是加载器的「类型注册表」:JSON 里每个节点/材质条目都带有 type 字段(即类名),解析时用它反查构造器并 new 出对应实例。若某个 type 不在字典中,NodeLoader.createNodeFromType 会打印 NodeLoader: Node type not found 错误并回退为一个 float() 节点占位,使解析过程不至于整体中断。
构造字典的推荐方式是通过配套的 TSL 节点库(three/tsl 导出的 nodes 字典)整体注入,例如:
import { nodes, nodeMaterials } from 'three/tsl';
const loader = new NodeObjectLoader();
loader.setNodes( nodes )
.setNodeMaterials( nodeMaterials );
从源码结构看,.nodes 字典会被透传给内部 NodeLoader(loader.setNodes( this.nodes )),而 .nodeMaterials 字典则透传给内部 NodeMaterialLoader(loader.setNodeMaterials( this.nodeMaterials )),两处注册表各司其职:前者负责还原节点实例,后者负责区分「节点材质」与「经典材质」。
parse / parseAsync:解析入口与节点上下文桥接
文档对两个解析入口的定义为:
.parse( json, onLoad ) : Object3D—— 从给定 JSON 解析节点对象,json为 JSON 定义,onLoad为回调函数,覆盖ObjectLoader#parse;.parseAsync( json ) : Promise.<Object3D>——parse的异步版本,覆盖ObjectLoader#parseAsync。
实现(NodeObjectLoader.js#L80-L108)非常精简,其核心是一个私有的 _nodesJSON 桥接字段:
parse( json, onLoad ) {
this._nodesJSON = json.nodes;
const data = super.parse( json, onLoad );
this._nodesJSON = null; // dispose
return data;
}
这段代码揭示了整个加载流程的关键设计:
- 暂存节点库:JSON 顶层的
nodes数组(全部节点定义)被先存入私有成员_nodesJSON; - 走父类流程:
super.parse()按ObjectLoader的标准管线依次解析 animations、shapes、geometries、images、textures,然后调用this.parseMaterials( json.materials, textures )解析材质——注意此处发生多态分派,实际执行的是NodeObjectLoader重写的parseMaterials; - 及时释放:解析完成后将
_nodesJSON置回null,避免对大型 JSON 的引用滞留。
也就是说,parse 本身不直接触碰节点树,它的职责是建立「节点 JSON → 材质解析」的上下文通道。父类 ObjectLoader.parse 中的调用顺序 animations → shapes → geometries → images → textures → materials → object → skeletons 在这里被完整继承,节点材质的解析恰好插入在 textures 之后、object 组装之前——因为材质的 inputNodes 引用的纹理对象必须先于材质存在。
parseMaterials:节点库先行,逐材质实例化
文档定义:
.parseMaterials( json, textures ) : Object.<string, NodeMaterial>—— Parses the node objects from the given JSON and textures. json: The JSON definition. textures: The texture library.
实现(NodeObjectLoader.js#L140-L165):
parseMaterials( json, textures ) {
const materials = {};
if ( json !== undefined ) {
const nodes = this.parseNodes( this._nodesJSON, textures );
const loader = new NodeMaterialLoader();
loader.setTextures( textures );
loader.setNodes( nodes );
loader.setNodeMaterials( this.nodeMaterials );
for ( let i = 0, l = json.length; i < l; i ++ ) {
const data = json[ i ];
materials[ data.uuid ] = loader.parse( data );
}
}
return materials;
}
执行逻辑可以拆成三步:
- 先把整个
nodes库还原成实例字典({ uuid: Node }),这是后续所有材质inputNodes引用的查找表; - 配置
NodeMaterialLoader:注入纹理库、刚解析出的节点实例库、以及构造期通过setNodeMaterials注册的材质类字典; - 遍历
json.materials数组,逐条调用loader.parse( data )并以uuid为键存入结果字典。
值得注意的是,父类 ObjectLoader.parseMaterials 使用的是普通 MaterialLoader(其文档明确标注 “This loader does not support node materials. Use NodeMaterialLoader instead.”,见 MaterialLoader.js#L35)。而 NodeMaterialLoader.createMaterialFromType 的查找策略是「先查 nodeMaterials 字典,未命中再回退到父类 MaterialLoader 的经典材质注册表」——这意味着同一份 JSON 可以混合包含 MeshStandardMaterial 等经典材质与 MeshPhysicalNodeMaterial 等节点材质,由同一个加载器兼容处理。
NodeMaterialLoader.parse 的另一关键动作是还原 inputNodes(NodeMaterialLoader.js#L41-L58):
parse( json ) {
const material = super.parse( json );
const nodes = this.nodes;
const inputNodes = json.inputNodes;
for ( const property in inputNodes ) {
const uuid = inputNodes[ property ];
material[ property ] = nodes[ uuid ];
}
return material;
}
即把序列化时以 UUID 字符串记录的节点引用(如 color: '<uuid>')重新绑定为真实的 Node 实例,完成材质到节点图的接线。
parseNodes:两遍式反序列化与 inputNodes 结构
文档定义 .parseNodes( json, textures ) : Object.<string, Node>,第一个参数是节点 JSON 数组,第二个参数是纹理库。NodeObjectLoader 的实现是一个轻量委托(NodeObjectLoader.js#L117-L131):
parseNodes( json, textures ) {
if ( json !== undefined ) {
const loader = new NodeLoader();
loader.setNodes( this.nodes );
loader.setTextures( textures );
return loader.parseNodes( json );
}
return {};
}
真正的两遍式解析在 NodeLoader.parseNodes 中:
- 第一遍(建实例):遍历
json数组,按type从字典createNodeFromType创建空实例,并保留uuid,形成{ uuid: Node }依赖表; - 第二遍(填数据):构造
meta = { nodes, textures }元数据并临时挂到每个nodeJSON.meta上,调用node.deserialize( nodeJSON )写入属性与子节点引用,随后delete nodeJSON.meta清理现场。
deserialize 端由 Node.deserialize 实现,它按 inputNodes 条目的形状分三种情况还原:
if ( Array.isArray( json.inputNodes[ property ] ) ) {
// 属性 → 节点数组,如多纹理输入
this[ property ] = [ ...uuids.map( uuid => nodes[ uuid ] ) ];
} else if ( typeof json.inputNodes[ property ] === 'object' ) {
// 属性 → 键控节点对象
for ( const subProperty in json.inputNodes[ property ] ) {
inputObject[ subProperty ] = nodes[ json.inputNodes[ property ][ subProperty ] ];
}
this[ property ] = inputObject;
} else {
// 属性 → 单个节点引用
this[ property ] = nodes[ json.inputNodes[ property ] ];
}
与之对应的序列化端是 Node.serialize:遍历 getSerializeChildren() 得到的子节点,带 index 的输入写入数组/对象形式的 inputNodes(键控输入用对象、索引输入用数组),无 index 的输入直接写入 UUID。这也解释了 JSON 中 inputNodes 的三种形态——单引用字符串、UUID 数组、UUID 对象——正好与 deserialize 的三个分支一一对应。
此外,Node.toJSON 会为每个节点生成 { uuid, type, metadata: { version: 4.7, type: 'Node', generator: 'Node.toJSON' } } 骨架,材质侧则由 NodeMaterial.toJSON 在标准 Material.prototype.toJSON 基础上追加 inputNodes。这些字段就是上述解析方法逐条消费的输入结构。
setNodes / setNodeMaterials:注册表配置 API
文档定义:
.setNodes( value : Object.<string, Node.constructor> ) : NodeObjectLoader—— 定义节点类型字典,value为<classname, class>形式的节点库,返回加载器自身引用以支持链式调用;.setNodeMaterials( value : Object.<string, NodeMaterial.constructor> ) : NodeObjectLoader—— 定义节点材质类型字典,同理。
两者实现均为「赋值 + 返回 this」(NodeObjectLoader.js#L53-L71),因此标准用法是:
const loader = new NodeObjectLoader( loadingManager )
.setNodes( nodes )
.setNodeMaterials( nodeMaterials );
这里有两个实践要点:
- 注册表必须在
parse之前完成配置:parseNodes/parseMaterials是临时创建NodeLoader/NodeMaterialLoader并当场注入字典的,晚于解析的配置不会生效; - 只影响「查找」,不影响「数据」:字典决定
type字符串映射到哪个类,节点的具体数值、纹理引用等数据仍完全来自 JSON 本身。
完整加载链路与源码索引
把整条链路串起来,一次 parseAsync 的调用关系为:
NodeObjectLoader.parseAsync(json)
├─ 暂存 json.nodes → this._nodesJSON
├─ ObjectLoader.parseAsync(json) // 标准 JSON Object/Scene 管线
│ ├─ parseGeometries / parseImagesAsync / parseTextures
│ ├─ NodeObjectLoader.parseMaterials(json.materials, textures) // 多态重写
│ │ ├─ NodeObjectLoader.parseNodes(_nodesJSON, textures)
│ │ │ └─ NodeLoader.parseNodes // 两遍式:建实例 → deserialize
│ │ └─ NodeMaterialLoader.parse // 经典材质回退 + inputNodes 接线
│ └─ parseObject / bindSkeletons / bindLightTargets
└─ 释放 _nodesJSON → 返回 Object3D
相关源码索引,便于继续深入:
| 文件 | 职责 |
|---|---|
| src/loaders/nodes/NodeObjectLoader.js | 本文主角:parse/parseAsync/parseMaterials/parseNodes 与两个字典属性 |
| src/loaders/nodes/NodeLoader.js | 节点级加载器:parseNodes 两遍式解析、createNodeFromType 回退策略 |
| src/loaders/nodes/NodeMaterialLoader.js | 节点材质加载器:节点材质优先、经典材质回退、inputNodes 还原 |
| src/loaders/ObjectLoader.js | 父类管线:JSON Object/Scene 格式的几何、纹理、对象解析 |
| src/nodes/core/Node.js | 节点序列化协议:serialize/deserialize/toJSON 与 inputNodes 结构 |
| src/materials/nodes/NodeMaterial.js | 节点材质序列化:在 Material.toJSON 基础上追加 inputNodes |
| docs/pages/NodeObjectLoader.html.md | 本主题的官方 API 文档(Constructor / Properties / Methods / Source) |
小结
NodeObjectLoader 的设计思路可以概括为三点:复用——完整继承 ObjectLoader 的 JSON Object/Scene 管线,只重写材质解析一环;分治——节点、材质、纹理各自成库,以 uuid 作为全局引用键,通过 inputNodes 完成跨库接线;可扩展——setNodes/setNodeMaterials 使加载器能够加载任何自定义 TSL 节点与自定义节点材质,而不必改动解析代码。对于需要在浏览器中持久化并回读「节点材质场景」的 TSL 项目(例如运行时编辑了 TSL 材质后保存场景再加载的场景),它是 NodeLoader 之上的正确选择:前者只解析单个节点依赖图,后者负责整棵对象树的完整还原。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00