Three.js TSL 中 AttributeNode 深度解析:把几何体属性变成可组合的着色器节点
本文以 Three.js 官方 API 参考页 AttributeNode 为主体,系统讲解 TSL(Three Shading Language)中"属性节点"这一基础构件:它如何把 BufferGeometry 上的顶点属性(position、normal、uv、color……)包装成参与节点图求值的对象,覆盖其构造函数、global 标志、getAttributeName / setAttributeName 接口,并结合仓库源码深入剖析类型推断、顶点/片元两阶段代码生成与序列化机制。读完后,你可以正确使用 attribute() TSL 函数编写自定义节点材质,并理解内置的 positionGeometry、normalGeometry、uv() 等访问器背后的完整调用链。
1. AttributeNode 是什么:TSL 属性访问的基类
官方文档页 AttributeNode.html.md 对它的定义只有一句话:Base class for representing shader attributes as nodes(表示着色器属性的节点基类)。在 three.js 的节点体系中,"属性"(attribute)指几何体上传给 GPU 的每顶点数据;而 TSL 不允许在节点图里直接写字符串引用这些缓冲区,必须通过一个节点对象作为桥梁——AttributeNode 就是所有这类桥梁的公共基类。
其继承链为:
EventDispatcher → Node → AttributeNode
即它先继承 EventDispatcher 的事件能力,再继承 Node 提供的节点图通用机制(名称、缓存、哈希、代码生成入口 generate(builder) 等),最后加上"属性名"这一核心概念。
源码入口为 src/nodes/core/AttributeNode.js,类声明与核心成员如下:
class AttributeNode extends Node {
static get type() {
return 'AttributeNode';
}
constructor( attributeName, nodeType = null ) {
super( nodeType );
this.global = true;
this._attributeName = attributeName;
}
// ...
}
值得注意的是,AttributeNode 在模块尾部额外导出了一个 TSL 函数 attribute(见 AttributeNode.js#L159-L168),这是用户真正日常调用的接口:
/**
* TSL function for creating an attribute node.
*
* @tsl
* @function
* @param {string} name - The name of the attribute.
* @param {?string} [nodeType=null] - The node type.
* @returns {AttributeNode}
*/
export const attribute = ( name, nodeType = null ) => new AttributeNode( name, nodeType );
官方 docs/TSL.md 的属性访问器表格中也将其列为标准成员:attribute( name, type = null ) —— "Getting geometry attribute using name and type"(按名称与类型获取几何体属性)。
2. 构造函数与两个核心参数
官方参考页给出的构造函数签名为:
new AttributeNode( attributeName : string, nodeType : string )
| 参数 | 类型 | 说明 | 默认值 |
|---|---|---|---|
attributeName |
string |
几何体上属性的名称,如 'position'、'color' |
无 |
nodeType |
string |
节点类型(着色器类型),如 'vec3'、'vec2'、'float' |
null |
结合 AttributeNode.js#L24-L38 的实现,有两个细节值得注意:
nodeType为null时并非"无类型",而是"延迟推断"。源码中的generateNodeType( builder )方法会在构建期动态决定类型(见第 4 节),若几何体上存在该属性,则按实际BufferAttribute推导,否则回退为'float'。- 构造函数同时把
this.global置为true,这是本类相对父类Node最显眼的行为差异。
3. .global 属性:为何属性节点默认是"全局"的
官方参考页 Properties 一节明确说明:
AttributeNodesets this property totrueby default. Default istrue. Overrides: Node#global
源码印证了这一点(AttributeNode.js#L28-L34):
/**
* `AttributeNode` sets this property to `true` by default.
*
* @type {boolean}
* @default true
*/
this.global = true;
在 Node 体系中,global 决定了节点是否跨构建上下文共享缓存(同一个节点对象被多次引用时只生成一份声明)。属性之所以默认 global,是因为同一个顶点属性往往会在节点图中被多次引用——例如 positionGeometry 这个模块级常量在整个着色器构建中被多处使用,若每次都声明一遍变量会造成冲突或冗余。这一点也体现在 Node.js#L633 的注释里:attribute( 'uv' ) 被多次使用时,构建期会做去重复用。
与之配套的还有 getHash( builder ) 的实现(AttributeNode.js#L40-L44):
getHash( builder ) {
return this.getAttributeName( builder );
}
即两个属性节点的哈希以属性名区分——同名属性共享同一构建结果,不同名属性各自独立。
4. 类型推断:generateNodeType 如何为 null 类型兜底
当构造时未指定 nodeType,源码 generateNodeType(AttributeNode.js#L46-L70)按如下逻辑处理:
generateNodeType( builder ) {
let nodeType = this.nodeType;
if ( nodeType === null ) {
const attributeName = this.getAttributeName( builder );
if ( builder.hasGeometryAttribute( attributeName ) ) {
const attribute = builder.geometry.getAttribute( attributeName );
nodeType = builder.getTypeFromAttribute( attribute );
} else {
nodeType = 'float';
}
}
return nodeType;
}
两条关键依赖都来自 NodeBuilder:
hasGeometryAttribute( name )(NodeBuilder.js#L1482-L1486):检查this.geometry.getAttribute( name ) !== undefined,即当前正在构建的几何体上是否存在该属性。getTypeFromAttribute( attribute )(NodeBuilder.js#L1728-L1748):根据BufferAttribute的itemSize、底层 TypedArray 类型以及normalized标志(Float16BufferAttribute与非归一化属性除外)推导着色器类型,例如 3 分量 Float32 数组推出vec3。
也就是说:attribute( 'position' ) 不写类型也能工作——构建器会查几何体真实数据并推出 vec3;而查不到属性时安全地退化为 float,避免构建期崩溃。
5. 代码生成:顶点阶段与片元阶段的行为分叉
generate( builder ) 是节点参与最终着色器输出的核心方法(AttributeNode.js#L102-L135),其行为按渲染阶段严格分叉:
generate( builder ) {
const attributeName = this.getAttributeName( builder );
const nodeType = this.getNodeType( builder );
const geometryAttribute = builder.hasGeometryAttribute( attributeName );
if ( geometryAttribute === true ) {
const attribute = builder.geometry.getAttribute( attributeName );
const attributeType = builder.getTypeFromAttribute( attribute );
const nodeAttribute = builder.getAttribute( attributeName, attributeType );
if ( builder.shaderStage === 'vertex' ) {
return builder.format( nodeAttribute.name, attributeType, nodeType );
} else {
const nodeVarying = varying( this );
return nodeVarying.build( builder, nodeType );
}
} else {
warn( `AttributeNode: Vertex attribute "${ attributeName }" not found on geometry.` );
return builder.generateConst( nodeType );
}
}
可以总结出三条规则:
- 顶点阶段:通过
builder.getAttribute( name, type )拿到(必要时新建并注册的)NodeAttribute声明,输出该属性的着色器变量名与类型。NodeBuilder.getAttribute(NodeBuilder.js#L1495-L1521)会先遍历已声明属性做去重,找不到才new NodeAttribute( name, type )并注册声明——这保证了多个节点引用同一属性时只声明一次。 - 片元阶段:顶点属性在 fragment shader 中不可直接读取,源码会构造
varying( this )节点,把该属性自动转成顶点着色器中声明、插值后传给片元的 varying 变量。这就是"同一属性节点在两个阶段写出不同代码"的机制。 - 属性缺失的降级:若几何体上没有该属性,不会抛出异常,而是打印警告
AttributeNode: Vertex attribute "xxx" not found on geometry.并生成一个类型常量为零值(builder.generateConst( nodeType )),保证着色器仍可编译。
6. 名称接口:getAttributeName 与 setAttributeName
官方参考页 Methods 一节列出的两个方法,是派生类定制属性名的"扩展点",文档原文强调:derived classes 可以覆写这两个方法,以在需要解析式计算最终名称时实现。
6.1 .getAttributeName( builder : NodeBuilder ) : string
Returns the attribute name of this node. The method can be overwritten in derived classes if the final name must be computed analytically.
基类实现极其简单(AttributeNode.js#L96-L100):
getAttributeName( /*builder*/ ) {
return this._attributeName;
}
但子类会覆写它。典型例子是 VertexColorNode(VertexColorNode.js#L51-L57):
getAttributeName( /*builder*/ ) {
const index = this.index;
return 'color' + ( index > 0 ? index : '' );
}
顶点颜色支持多套(color、color1……),属性名无法在构造时静态确定,必须按 index 解析式计算——这正是文档所说"analytically computed"的场景。VertexColorNode 还把缺失颜色属性时的降级值从"零值"改成了白色 (1,1,1,1)(VertexColorNode.js#L59-L79),说明子类覆写 generate 可进一步定制兜底行为。
6.2 .setAttributeName( attributeName : string ) : AttributeNode
Sets the attribute name to the given value. … Returns: A reference to this node.
基类实现返回 this(AttributeNode.js#L80-L86),支持链式调用,同样供子类覆写。
7. 序列化:serialize / deserialize 支持节点编辑器往返
参考页虽未展开,但源码中 AttributeNode 实现了完整的序列化接口(AttributeNode.js#L137-L153):
serialize( data ) {
super.serialize( data );
data.global = this.global;
data._attributeName = this._attributeName;
}
deserialize( data ) {
super.deserialize( data );
this.global = data.global;
this._attributeName = data._attributeName;
}
即 global 标志与属性名两个状态都可无损写入/恢复,配合仓库中的节点编辑能力(如 webgpu_tsl_editor.html 示例所依托的节点序列化机制),属性节点可以作为图中可保存、可回放的一等公民存在。
8. 实践:attribute() 函数与内置访问器
8.1 直接使用 attribute()
最简单的用法就是按名称取属性,并显式指定类型:
import { attribute, material, color } from 'three/tsl';
// 在几何体上定义 aRandom 属性(float),然后在节点图中引用
const geom = new THREE.IcosahedronGeometry( 1, 4 );
const count = geom.attributes.position.count;
const random = new Float32Array( count );
for ( let i = 0; i < count; i ++ ) random[ i ] = Math.random();
geom.setAttribute( 'aRandom', new THREE.BufferAttribute( random, 1 ) );
const materialNode = material( color( 'black' ).mul( attribute( 'aRandom' ) ) );
// mesh.material = materialNode
显式传类型(如 attribute( 'position', 'vec3' ))可以跳过第 4 节的推断路径,语义更明确。
8.2 内置访问器:全是 attribute() 的实例
three.js 内置的几何体访问器本身就是 AttributeNode 的现成实例,可作为编写自定义节点时的参照:
| 内置节点 | 定义位置 | 等价写法 |
|---|---|---|
positionGeometry |
Position.js#L33 | attribute( 'position', 'vec3' ) |
normalGeometry |
Normal.js#L15 | attribute( 'normal', 'vec3' ) |
tangentGeometry |
Tangent.js#L14 | attribute( 'tangent', 'vec4' ) |
uv( index ) |
UV.js#L11 | attribute( 'uv' + ( index > 0 ? index : '' ), 'vec2' ) |
| skinIndex / skinWeight | Skinning.js#L234-L235 | attribute( 'skinIndex', 'uvec4' )、attribute( 'skinWeight', 'vec4' ) |
uv( index ) 的命名规则(uv、uv1……)与第 6 节 VertexColorNode 的解析式命名思路一致,体现了"名称可由运行时参数解析"这一设计的普遍性。
8.3 仓库中的其他真实用例
从源码结构看,attribute() 也被用在内置管线里,例如:
- 粗线渲染:Line2NodeMaterial.js 中
attribute( 'instanceStart' )、attribute( 'instanceEnd' )、attribute( 'instanceDistanceStart' )等,引用的是该材质注入几何体的实例化属性; - 虚线材质:LineDashedNodeMaterial.js#L123 中
varying( attribute( 'lineDistance' ).mul( dashScaleNode ) ),展示了"属性节点 → 运算 → varying"的典型组合; - PMREM 预处理:PMREMGenerator.js#L66 中
attribute( 'outputDirection' ).normalize()。
这些用法共同说明:AttributeNode 不仅是用户侧的 API,也是 TSL 管线内部把各种顶点级数据纳入节点图求值的统一手段。
9. 小结与要点回顾
| 成员 | 作用 | 源码依据 |
|---|---|---|
constructor( attributeName, nodeType = null ) |
创建属性节点;类型可为 null 延迟推断;同时置 global = true |
AttributeNode.js#L24-L38 |
.global |
覆盖 Node#global,默认 true,保证同一属性跨构建去重复用 |
AttributeNode.js#L34 |
getAttributeName( builder ) |
返回属性名;派生类可覆写为解析式命名 | AttributeNode.js#L96-L100 |
setAttributeName( name ) |
设置属性名并返回 this,可链式调用 |
AttributeNode.js#L80-L86 |
generate( builder ) |
顶点阶段输出属性声明引用;片元阶段经 varying 传递;属性缺失时警告并降级为常量 |
AttributeNode.js#L102-L135 |
attribute( name, nodeType ) |
TSL 工厂函数,日常使用入口 | AttributeNode.js#L168 |
一句话概括:AttributeNode 是 TSL 中"GPU 顶点数据"进入节点图的统一入口——用名称定位数据、用类型系统(显式或推断)约束求值、用 global 语义做声明去重、用顶点/片元分叉处理插值语义,并保留解析式命名与序列化的扩展点。理解了它,就理解了 positionGeometry、normalGeometry、uv() 等一切内置访问器,以及自定义顶点数据在节点材质中流转的完整机制。
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 StartedRust0622
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