普通视图

发现新文章,点击刷新页面。
昨天以前掘金 前端

【节点】[Distance节点]原理解析与实际应用

作者 SmalBox
2026年5月23日 19:08

【Unity Shader Graph 使用与特效实现】专栏-直达

Distance 节点是 Unity URP Shader Graph 中的一个重要数学运算节点,它计算两个输入向量之间的欧几里德距离。欧几里德距离是几何学中最常见的距离度量方式,表示在 n 维空间中两点之间的直线距离。

在计算机图形学和着色器编程中,Distance 节点具有广泛的应用场景。它不仅用于基本的距离计算,还是实现各种高级视觉效果的基础工具。该节点特别适用于计算有符号距离函数(Signed Distance Function,SDF),这是现代实时渲染中用于描述几何形状边界的重要数学工具。

Distance 节点的工作原理基于欧几里德距离公式。对于二维空间中的两点 A(x₁, y₁) 和 B(x₂, y₂),距离计算公式为:√[(x₂-x₁)² + (y₂-y₁)²]。对于三维空间,公式扩展为:√[(x₂-x₁)² + (y₂-y₁)² + (z₂-z₁)²]。Shader Graph 中的 Distance 节点自动处理这些计算,支持从一维到四维的向量输入。

该节点在视觉效果创作中的重要性体现在多个方面:

  • 它是创建基于距离的渐变、过渡效果的基础
  • 用于实现物体边缘发光、轮廓检测等效果
  • 在程序化生成内容中用于形状描述和空间划分
  • 是许多高级渲染技术如距离场渲染、光线步进的基础构建块

数学原理

欧几里德距离基础

欧几里德距离是衡量空间中两点间"直线"距离的标准方法。在着色器编程中理解其数学原理对于有效使用 Distance 节点至关重要。

对于不同维度的向量,距离计算略有不同:

  • 一维向量:Distance = |A - B|
  • 二维向量:Distance = √[(A.x - B.x)² + (A.y - B.y)²]
  • 三维向量:Distance = √[(A.x - B.x)² + (A.y - B.y)² + (A.z - B.z)²]
  • 四维向量:Distance = √[(A.x - B.x)² + (A.y - B.y)² + (A.z - B.z)² + (A.w - B.w)²]

在 Shader Graph 中,无论输入向量的维度如何,Distance 节点始终输出一个浮点数值,表示两个输入向量之间的绝对距离。

距离计算的实际考虑

在实际着色器应用中,出于性能考虑,有时会使用距离的平方而不是实际距离。这是因为平方根计算在 GPU 上相对昂贵。Distance 节点内部确实计算了完整的欧几里德距离,包括平方根操作,但在某些性能敏感的场景中,开发者可能会选择手动计算平方距离来避免平方根开销。

例如,当只需要比较距离大小时(如找出最近的点),使用平方距离就足够了,因为平方根函数是单调递增的,距离的大小关系与平方距离的大小关系一致。

端口详解

输入端口

A 端口

  • 方向:输入
  • 类型:动态矢量(Float,Vector2,Vector3,Vector4)
  • 描述:第一个输入向量,代表空间中的一个点或位置。这个端口可以接受不同维度的向量输入,根据连接的数据类型自动适应。在实际应用中,A 端口通常代表需要计算距离的起始点或参考点。

B 端口

  • 方向:输入
  • 类型:动态矢量(Float,Vector2,Vector3,Vector4)
  • 描述:第二个输入向量,代表空间中的另一个点或位置。与 A 端口一样,B 端口也支持动态向量类型,但必须与 A 端口保持相同的维度。B 端口通常代表目标点或需要测量距离的终点。

输出端口

Out 端口

  • 方向:输出
  • 类型:Float
  • 描述:输出 A 和 B 之间的欧几里德距离,始终为标量值。无论输入向量的维度如何,输出都是单个浮点数,表示两点之间的绝对距离。这个值总是非负的,因为距离没有方向性。

端口连接规范

Distance 节点对输入端口有一些重要的连接要求:

  • A 和 B 端口必须连接相同维度的向量类型
  • 如果连接不同维度的向量,Shader Graph 会显示编译错误
  • 输入端口支持直接连接常量值、属性、其他节点的输出或图形输入节点
  • 输出端口可以连接到任何接受浮点数输入的端口

使用方法和技巧

基本连接方法

使用 Distance 节点的基本步骤很简单:

  • 将需要计算距离的两个向量分别连接到 A 和 B 端口
  • 将 Out 端口连接到需要使用距离值的后续节点
  • 根据需要调整后续节点的处理逻辑

典型的基本设置包括:

  • 连接两个 Position 节点来计算空间中两点的距离
  • 连接 UV 坐标和固定点来计算基于纹理坐标的距离
  • 连接时间动画的向量来创建动态距离效果

性能优化技巧

虽然 Distance 节点使用方便,但在性能关键的场景中需要考虑优化:

  • 在片段着色器中频繁使用 Distance 节点可能影响性能,特别是移动平台
  • 对于只需要距离比较的场景,考虑使用点积运算手动计算平方距离
  • 在顶点着色器中预计算距离然后插值到片段着色器可以提高性能
  • 对于静态场景,考虑将距离计算烘焙到纹理中

常见应用模式

Distance 节点在着色器创作中有几种经典的应用模式:

径向渐变模式:

  • 使用 Distance 节点计算当前片段到中心点的距离
  • 将距离值映射到 0-1 范围作为渐变系数
  • 使用渐变系数混合颜色或透明度

边缘检测模式:

  • 计算到边界或特定位置的距离
  • 使用步进或平滑步进函数创建清晰的边缘
  • 可以用于创建描边、发光边界等效果

距离场渲染模式:

  • 使用 Distance 节点计算到多个物体的距离
  • 通过距离函数组合创建复杂形状
  • 利用有符号距离函数实现高级几何渲染

实际应用案例

案例一:创建径向渐变着色器

径向渐变是 Distance 节点最直接的应用之一。以下是创建简单径向渐变的步骤:

  • 在 Shader Graph 中创建新的 Unlit Graph
  • 添加 Position 节点并设置为 Absolute World
  • 添加 Vector3 属性作为渐变中心点,默认值设为 (0,0,0)
  • 将 Position 和中心点属性连接到 Distance 节点的 A 和 B 端口
  • 添加 Divide 节点将距离值除以外半径值进行标准化
  • 添加 Saturate 节点确保结果在 0-1 范围内
  • 使用标准化后的距离值作为 Lerp 节点的系数,混合内外颜色
  • 连接到 Fragment 节点的 Base Color 端口

这种技术可以扩展为创建复杂的径向背景、能量护盾效果或聚焦光照效果。

案例二:实现物体边缘发光

使用 Distance 节点可以检测物体边缘并添加发光效果:

  • 使用 Object 节点的 Position 输出作为基础位置
  • 添加 Camera 节点的 World Position 作为观察点参考
  • 计算物体表面点到摄像机方向的垂直距离
  • 当距离小于阈值时应用发光颜色
  • 使用指数函数控制发光的衰减曲线
  • 将发光效果与基础颜色相加混合

这种方法可以创建科幻风格的轮廓光、危险物品警示效果或魔法特效。

案例三:制作交互式溶解效果

Distance 节点非常适合创建基于距离的溶解效果:

  • 计算每个片段到交互点(如玩家位置)的距离
  • 将距离与阈值比较,决定是否溶解
  • 使用噪声纹理为溶解边缘添加细节
  • 根据距离控制溶解边缘的发光强度
  • 添加动画使溶解效果随时间传播

这种效果常用于角色死亡、物体破坏或魔法传送等游戏场景。

与其他节点的配合使用

与数学节点配合

Distance 节点经常与各种数学节点结合使用以实现更复杂的效果:

  • 与 Divide 节点配合:标准化距离值,将其映射到特定范围
  • 与 Multiply 节点配合:调整距离的影响强度或创建重复模式
  • 与 Add/Subtract 节点配合:偏移距离基准点或创建距离偏移效果
  • 与 Power 节点配合:创建非线性的距离衰减曲线

与高级函数节点配合

Distance 节点与一些特殊函数节点结合可以创建专业级效果:

  • 与 Remap 节点配合:将距离从原始范围重新映射到新范围
  • 与 Smoothstep 节点配合:创建平滑的距离过渡区域
  • 与 Fraction 节点配合:基于距离创建重复图案
  • 与 Noise 节点配合:为距离效果添加有机变化

在节点组中的角色

在复杂的着色器中,Distance 节点通常作为更大节点网络的一部分:

  • 在距离场着色器中作为基础距离计算单元
  • 在光照模型中作为衰减计算的基础
  • 在后期处理效果中作为空间遮罩生成器
  • 在程序化生成中作为形状描述的基本操作

生成的代码示例分析

代码结构解析

Distance 节点生成的 HLSL 代码反映了其核心功能:

HLSL

void Unity_Distance_float4(float4 A, float4 B, out float Out)
{
    Out = distance(A, B);
}

这段代码展示了一个典型的四维向量距离计算函数。分析代码结构:

  • 函数名为 Unity_Distance_float4,表明处理的是 float4 类型
  • 接受两个 float4 参数 A 和 B
  • 通过 out 参数返回计算结果
  • 使用 HLSL 内置的 distance() 函数进行实际计算

内置 distance 函数

HLSL 中的 distance() 函数是 Distance 节点的核心实现:

HLSL

// HLSL 内置 distance 函数的近似实现
float distance(float4 a, float4 b)
{
    float4 diff = a - b;
    return sqrt(dot(diff, diff));
}

这个实现展示了欧几里德距离的实际计算过程:

  • 首先计算两个向量的差值
  • 然后计算差值的点积(即各分量平方和)
  • 最后对点积结果取平方根得到实际距离

不同维度的变体

Shader Graph 会根据输入向量维度生成不同的函数变体:

对于二维向量:

HLSL

void Unity_Distance_float2(float2 A, float2 B, out float Out)
{
    Out = distance(A, B);
}

对于三维向量:

HLSL

void Unity_Distance_float3(float3 A, float3 B, out float Out)
{
    Out = distance(A, B);
}

这些变体确保了无论输入数据维度如何,都能正确计算距离。

故障排除和常见问题

编译错误和解决方案

在使用 Distance 节点时可能遇到的一些常见编译错误:

维度不匹配错误:

  • 问题:A 和 B 端口连接了不同维度的向量
  • 解决方案:确保两个输入端口使用相同维度的向量类型

类型不兼容错误:

  • 问题:尝试连接不支持的数据类型到输入端口
  • 解决方案:只使用浮点数类型的向量(Float/Vector2/Vector3/Vector4)

循环依赖错误:

  • 问题:节点连接形成了循环引用
  • 解决方案:检查节点连接,确保数据流向是单向的

性能问题诊断

Distance 节点可能引起的性能问题及解决方法:

片段着色器过载:

  • 症状:在片段着色器中大量使用 Distance 节点导致帧率下降
  • 解决方案:将计算移至顶点着色器,或使用简化距离计算

精度问题:

  • 症状:在远距离时出现精度误差或闪烁
  • 解决方案:使用更高精度的浮点数,或重新设计距离计算范围

移动端性能问题:

  • 症状:在移动设备上性能显著下降
  • 解决方案:减少 Distance 节点使用频率,使用近似计算或预计算

视觉效果问题

使用 Distance 节点时可能遇到的视觉效果问题:

距离计算不准确:

  • 问题:计算的距离与预期不符
  • 检查点:确认使用的坐标空间是否正确,检查向量分量是否完整

渐变效果不连续:

  • 问题:基于距离的渐变出现明显边界或断层
  • 解决方案:检查距离标准化过程,确保使用正确的插值函数

边缘效果闪烁:

  • 问题:基于距离的边缘效果在摄像机移动时闪烁
  • 解决方案:为距离计算添加适当的偏导数或使用屏幕空间技术

高级应用和创意用法

有符号距离函数(SDF)应用

Distance 节点是实现有符号距离函数的基础。SDF 是描述几何形状的强大数学工具:

基本 SDF 形状:

  • 球体 SDF:distance(p, center) - radius
  • 盒子 SDF:计算点到立方体边界的有符号距离
  • 平面 SDF:dot(p, normal) - distance

SDF 布尔运算:

  • 并集:min(d1, d2)
  • 交集:max(d1, d2)
  • 差集:max(d1, -d2)

SDF 扭曲和变形:

  • 通过噪声函数扭曲距离场
  • 使用三角函数创建重复图案
  • 结合时间变量创建动画 SDF

程序化动画和交互

Distance 节点可以驱动各种程序化动画效果:

波浪传播效果:

  • 基于到源点的距离控制波浪相位
  • 使用正弦函数创建波浪形状
  • 结合时间变量创建传播动画

粒子吸引/排斥系统:

  • 计算粒子到吸引点的距离
  • 根据距离决定作用力强度和方向
  • 创建自然的粒子运动行为

交互式变形效果:

  • 基于到交互点的距离变形网格
  • 使用距离控制变形强度和范围
  • 创建响应玩家操作的动态环境

高级渲染技术

Distance 节点在现代渲染技术中扮演重要角色:

距离场软阴影:

  • 使用距离场信息计算柔和阴影
  • 通过多次距离查询估计遮挡程度
  • 创建高质量的场景阴影效果

光线步进渲染:

  • 使用距离场指导光线前进步骤
  • 大幅提高复杂几何体的渲染效率
  • 实现实时体渲染和复杂参数曲面渲染

全局光照近似:

  • 基于距离估计间接光照贡献
  • 创建简化的环境光遮蔽效果
  • 实现性能友好的全局光照近似

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

【节点】[HyperbolicSine节点]原理解析与实际应用

作者 SmalBox
2026年5月19日 09:13

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity的Shader Graph可视化着色器编辑器中,Hyperbolic Sine(双曲正弦)节点是一个重要的数学运算节点,用于计算输入值的双曲正弦函数值。这个节点为着色器编程提供了强大的数学工具,特别适用于创建复杂的动画效果、波形变形和特殊视觉效果。双曲正弦函数是双曲函数家族中的一员,与传统的三角函数有着密切的数学关系,但在图形编程中具有独特的应用价值。

Hyperbolic Sine节点在Shader Graph中的主要作用是将输入的标量或矢量值转换为对应的双曲正弦值。这个转换过程是基于双曲函数的数学定义,能够产生非周期性的指数增长曲线,这种特性使得它在模拟自然现象和创建特殊视觉效果时特别有用。与普通的正弦函数不同,双曲正弦函数产生的是非周期性的曲线,这为着色器设计师提供了更多样化的工具来创造独特的视觉体验。

在实时图形渲染中,数学函数节点是构建复杂着色器效果的基础构建块。Hyperbolic Sine节点作为其中的一员,能够帮助开发者在不编写复杂代码的情况下实现高级数学运算。通过将这个节点与其他Shader Graph节点结合使用,可以创造出从简单的颜色渐变到复杂的几何变形等各种视觉效果。

描述

Hyperbolic Sine节点的核心功能是计算输入值的双曲正弦值。在数学上,双曲正弦函数定义为sinh(x) = (e^x - e^(-x))/2,其中e是自然对数的底数。这个函数产生一条通过原点且对称于原点的曲线,随着x值的增大,函数值呈指数级增长。

在Shader Graph中,Hyperbolic Sine节点接受一个输入值In,并返回对应的双曲正弦值作为输出Out。这个节点可以处理各种数据类型,包括浮点数、二维向量、三维向量和四维向量,使其在着色器编程中具有很高的灵活性。当输入是矢量时,节点会分别对每个分量计算双曲正弦值,这意味着它可以同时处理多个数值通道。

双曲正弦函数在图形编程中有多种应用场景:

  • 创建非周期性的波形动画
  • 实现指数增长的强度效果
  • 模拟自然现象如涟漪扩散
  • 生成特殊的变形效果

与标准正弦函数相比,双曲正弦函数的特点在于它的非周期性。标准正弦函数产生的是在-1到1之间振荡的周期性波形,而双曲正弦函数则产生从负无穷到正无穷单调递增的曲线。这一特性使得它在需要单向增长或衰减的效果中特别有用。

在着色器性能方面,Hyperbolic Sine节点的计算开销相对较高,因为它涉及指数运算。在移动平台或性能受限的环境中,应谨慎使用这个节点,特别是在每帧都需要计算的情况下。对于需要高性能的场景,可以考虑使用近似函数或查找表来替代精确的双曲正弦计算。

端口

Hyperbolic Sine节点的端口设计遵循了Shader Graph的标准规范,提供了清晰的输入输出接口,使得节点可以轻松地与其他节点连接和组合。

输入端口

In(输入)端口是节点的唯一输入接口,它接受一个动态矢量类型的值。这里的"动态矢量"意味着这个端口可以接受多种数据类型,包括:

  • float(浮点数)
  • float2(二维向量)
  • float3(三维向量)
  • float4(四维向量)

这种灵活性使得节点可以适应各种不同的使用场景。例如,当输入是一个float4类型的颜色值时,节点会对每个颜色通道(R、G、B、A)分别计算双曲正弦值,从而产生一个新的颜色值。这种逐分量计算的特性使得节点可以同时处理多个数据通道,大大提高了着色器编程的效率。

输入值的范围没有严格的限制,但需要注意的是,双曲正弦函数对于很大的输入值会产生极大的输出值,这可能会导致数值精度问题或非预期的视觉效果。在实际使用中,通常需要对输入值进行适当的缩放或限制,以确保输出值在合理的范围内。

输出端口

Out(输出)端口返回计算后的双曲正弦值,其数据类型与输入端口相同。如果输入是float类型,输出也是float;如果输入是float3类型,输出也是float3,以此类推。这种保持数据类型一致性的设计使得节点可以无缝地集成到复杂的节点网络中。

输出值的特性取决于输入值:

  • 当输入为0时,输出也为0,因为sinh(0) = 0
  • 当输入为正数时,输出为正数,且随着输入值的增大呈指数级增长
  • 当输入为负数时,输出为负数,且随着输入值的减小(绝对值增大)呈指数级减小

这种不对称的增长特性使得双曲正弦函数在创建单向渐变效果时特别有用。例如,可以用来创建从中心向边缘逐渐增强的光晕效果,或者模拟爆炸冲击波的传播。

在实际应用中,输出值往往需要经过后续处理才能用于最终的渲染。常见的后续处理包括:

  • 使用Clamp节点将输出值限制在特定范围内
  • 使用Normalize节点对输出矢量进行归一化
  • 使用Remap节点重新映射输出值的范围
  • 与其他数学运算节点结合创建更复杂的效果

了解这些端口的特性和行为对于有效使用Hyperbolic Sine节点至关重要。通过合理配置输入值和适当处理输出值,可以创造出各种令人印象深刻的视觉效果。

生成的代码示例

当在Shader Graph中使用Hyperbolic Sine节点时,Unity会在背后生成对应的HLSL代码。理解这些生成的代码有助于深入理解节点的工作原理,并且在需要编写自定义着色器代码时提供参考。

以下示例代码表示此节点的一种可能结果:

void Unity_HyperbolicSine_float4(float4 In, out float4 Out)
{
    Out = sinh(In);
}

这段代码展示了一个典型的HLSL函数实现,它接受一个float4类型的输入参数In,并通过输出参数Out返回计算结果。函数内部使用了HLSL内置的sinh()函数来计算双曲正弦值。

代码分析

从生成的代码中我们可以看出几个重要特点:

  • 函数名为Unity_HyperbolicSine_float4,这表明它是针对float4类型的特化实现
  • 函数使用HLSL的sinh()内置函数,这是一个经过优化的数学函数
  • 参数传递使用out关键字,这是一种高效的输出参数传递方式

对于不同的输入数据类型,Unity会生成相应的特化版本。例如,对于float输入,可能会生成如下代码:

void Unity_HyperbolicSine_float(float In, out float Out)
{
    Out = sinh(In);
}

对于float2输入:

void Unity_HyperbolicSine_float2(float2 In, out float2 Out)
{
    Out = sinh(In);
}

这种针对不同数据类型的特化实现确保了代码的高效性,同时保持了接口的一致性。

性能考虑

在性能方面,双曲正弦计算属于相对昂贵的数学运算,特别是在移动设备上。以下是一些优化建议:

  • 避免在片段着色器中频繁计算双曲正弦,特别是在高分辨率屏幕上
  • 考虑在顶点着色器中预先计算值,然后通过插值传递给片段着色器
  • 对于不需要高精度的场景,可以使用近似公式替代精确计算

一个简单的双曲正弦近似公式是:

sinh(x) ≈ x + x³/6  // 对于小x值的近似

但这种近似只在一定范围内有效,需要根据具体需求选择合适的计算方法。

实际应用示例

以下是一个更完整的HLSL代码示例,展示了如何在自定义着色器中使用双曲正弦函数:

Shader "Custom/HyperbolicSineExample"
{
    Properties
    {
        _Amplitude ("Amplitude", Range(0, 5)) = 1
        _Frequency ("Frequency", Range(0, 10)) = 1
        _Speed ("Speed", Range(0, 5)) = 1
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            float _Amplitude;
            float _Frequency;
            float _Speed;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // 使用双曲正弦创建波动效果
                float wave = sinh((i.uv.x - 0.5) * _Frequency + _Time.y * _Speed) * _Amplitude;

                // 将波动效果应用于颜色
                float3 color = lerp(float3(0, 0, 1), float3(1, 0, 0), wave * 0.1 + 0.5);

                return fixed4(color, 1);
            }
            ENDCG
        }
    }
}

这个示例着色器使用双曲正弦函数创建了一个非周期性的波形效果,可以用于模拟特殊的液体表面或能量场效果。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

用官方模板理解 Decky 插件:一次从模板到架构的速览

作者 jump_jump
2026年5月8日 10:43

面向第一次接触 Steam Deck 插件开发的读者。本文以官方仓库 decky-plugin-template 为索引,逐个文件讲清它们为什么存在、如何协作,并给出模板之外、上线前必遇的几个坑。

TL;DR

  • 一个 Decky 插件 = Steam CEF 里的 React 组件 + SteamOS 上的 Python 进程,两者通过 Decky Loader 提供的 callable / emit 通信;
  • 前端入口固定是 src/index.tsxdefinePlugin(factory),后端入口固定是 main.py 里的 class Plugin,方法必须 async、参数必须 JSON-safe;
  • 路径、日志、配置全部走 decky.DECKY_PLUGIN_* 常量,不要硬编码 ~/homebrew/...
  • 开发流程靠 .vscode/ 下一套 shell 脚本闭环(build → rsync → 重启 plugin_loader),不用 VS Code 也能复刻;
  • 需要 root 就加 _root flag,但能用精确 sudo 解决的就别加——商店审核不喜欢。

Decky 插件到底是什么

Steam Deck 的游戏模式并不是一个独立 UI,而是 Steam 客户端内部的一组 CEF(Chromium Embedded Framework)页面。Valve 没有公开扩展 API,于是社区做了一个注入框架 —— Decky Loader。它做三件事:

  1. 注入 UI:在游戏模式的侧边栏挂一个入口,加载第三方插件的 React 组件;
  2. 管理后端:为每个插件拉起一个独立的 Python 进程,提供生命周期钩子与 RPC 通道;
  3. 约定目录:给每个插件划拨固定的配置、运行时、日志目录,写在 decky.DECKY_PLUGIN_* 这一组环境变量里。

所以一个 Decky 插件的最小认知是:

一个跑在 Steam 客户端 CEF 里的 React 组件 + 一个跑在 SteamOS 上的 Python 小后端,两者通过 Decky Loader 的 RPC/事件总线通信。

下文以官方模板作为参照,逐个文件拆开看。

模板长什么样

克隆官方模板后,目录结构大致如下(无关文件省略):

decky-plugin-template/
├── plugin.json              # 插件元数据
├── main.py                  # Python 后端入口,类名必须是 Plugin
├── package.json             # 前端依赖(pnpm 管理)
├── rollup.config.js         # 使用 @decky/rollup 官方 preset
├── tsconfig.json
├── decky.pyi                # decky 运行时模块的类型存根,供 IDE 用
├── src/
│   ├── index.tsx            # 前端入口,默认导出 definePlugin(...)
│   └── types.d.ts           # 让 TS 识别 *.png / *.svg / *.jpg 资源
├── assets/
│   └── logo.png             # 插件图标/资源
├── defaults/
│   └── defaults.txt         # 会被打进插件根目录的静态文件
├── py_modules/              # 第三方 Python 依赖放这里(vendored)
├── backend/                 # 原生后端(可选),用 Docker + Make 构建
│   ├── Dockerfile
│   ├── Makefile
│   ├── entrypoint.sh
│   └── src/main.c
└── .vscode/                 # 一键 setup/build/deploy 任务
    ├── tasks.json
    ├── setup.sh
    ├── build.sh
    ├── config.sh
    └── defsettings.json

这个结构看似很多,但按职责其实只有四组:插件声明plugin.jsonpackage.json)、前端运行时src/rollup.config.jstsconfig.json)、后端运行时main.pypy_modules/backend/defaults/)、开发工作流.vscode/ 下的脚本与任务)。下面每一节就按这四组展开。

运行时的调用链可以用一张图收拢:

        Steam Client (CEF)                    SteamOS (userland)
+-----------------------------------+   +-----------------------------------+
|  Steam UI                         |   |  Decky Loader (systemd service)   |
|  (hosts shared React, external'd) |   |     |                             |
|     |                             |   |     +--> python main.py           |
|     +--> dist/index.js            |   |          (class Plugin, 1 proc)   |
|          (ESM, injected by Loader)|   |               ^                   |
|               ^                   |   |               | async def xxx     |
|               | definePlugin()    |   |               |                   |
|               |                   |   |               |                   |
|          callable("xxx", args) ---+---+---> RPC ----->|                   |
|                                   |   |                                   |
|          addEventListener <-------+---+---- decky.emit("evt", ...)        |
+-----------------------------------+   +-----------------------------------+

前端 ESM bundle 和后端 Python 进程物理上互不知晓,唯一的桥是 Decky Loader 提供的一对原语:callable / emit。后面每一节本质上都在讲这张图里某一块的细节。

插件声明:plugin.jsonpackage.json

plugin.json 是 Decky Loader 在加载插件时第一个读到的文件。官方模板里长这样:

{
  "name": "Example Plugin",
  "author": "John Doe",
  "flags": ["debug", "_root"],
  "api_version": 1,
  "publish": {
    "tags": ["template", "root"],
    "description": "Decky example plugin.",
    "image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
  }
}

几处容易被忽略的细节:

  • name 是展示名,不是插件目录名
    • 它是环境变量 DECKY_PLUGIN_NAME,也是菜单/商店里显示给用户的名字;
    • 真正的插件目录由安装时的 zip 顶层目录/安装路径决定,Decky Loader 源码里 plugin.json.name 和实际的 plugin_directory 是两个独立概念——配置、日志、运行时目录用的是"目录名",不是这里的展示名;
    • 模板 .vscode/defsettings.json 里的 pluginname 只是部署脚本的变量,它决定 rsync 到 Deck 上的目标目录叫什么,并不是一种"绑定关系",只是多数人习惯让两者保持一致;
    • 不要据此手拼 ~/homebrew/settings/<name>/ 这类配置目录,真实的设置、运行时、日志路径请统一读 decky.DECKY_PLUGIN_*_DIR 常量。改过插件目录名、包名或历史配置路径时,用 _migration 钩子迁移旧数据(下文讲)。
  • flags 是权限/行为声明,目前 Decky Loader 实际消费的是下面这两个:
    • _root / root:含义是让后端以 root 身份运行,能访问 /usr/ 下的系统文件。历史上模板里的 key 与 Loader 源码中判断的 key 存在过命名不一致(_root vs root),近期有所统一——提交前请以当前 SteamDeckHomebrew/decky-loader 源码及 decky-plugin-database CI 的校验结果为准,不要把任何一侧当作权威;
    • debug:在开发期打开额外日志。
    • 模板默认开着 _root 只是为了演示能力,真实插件通常不要主动开 _root——能用 subprocess + 精确 sudo 命令解决的事,就不要让整个后端进程都带特权。社区经验上,带 _root 的插件在商店审核时也更容易被打回。其他 flags 值 Loader 当前会忽略,不要依赖未文档化的行为。
  • api_version 目前固定是 1,未来协议升级时会变。
  • publish 段仅用于 decky-plugin-database 上架,开发期不写也能跑。

package.json 则声明前端依赖。完整版里还会包含仓库元数据、test 占位脚本等——下面列出与构建/运行直接相关的字段:

{
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "watch": "rollup -c -w"
  },
  "devDependencies": {
    "@decky/rollup": "^1.0.2",
    "@decky/ui": "^4.11.0",
    "@rollup/rollup-linux-x64-musl": "^4.53.3",
    "@types/react": "19.1.1",
    "@types/react-dom": "19.1.1",
    "@types/webpack": "^5.28.5",
    "rollup": "^4.53.3",
    "typescript": "^5.6.2"
  },
  "dependencies": {
    "@decky/api": "^1.1.3",
    "react-icons": "^5.3.0",
    "tslib": "^2.7.0"
  },
  "pnpm": {
    "peerDependencyRules": {
      "ignoreMissing": ["react", "react-dom"]
    }
  }
}

@rollup/rollup-linux-x64-musl 是模板显式声明的依赖,用来兜底 Rollup 在某些构建环境里加载不到对应 native binding 的情况——少了它 Rollup 可能直接报 "Cannot find module" 而终止。注意:SteamOS 3 / Holo 本身是 Arch 系 glibc 发行版,并不是 musl 发行版,这里加这个依赖是为了让 Rollup 的原生 binding 解析更稳,不要把它理解成"holo 镜像基于 musl"。

这里有两条"反直觉"的点一定要记住:

  • 不要自己安装 react / react-dom 作为运行时依赖:Decky Loader 在 Steam CEF 里已经提供了一份共享的 React 实例,你再打进一份,hook 很容易因为运行时实例不一致而报错。@decky/ui 声明了 react / react-dom 作为 peerDependency,模板里的 pnpm.peerDependencyRules.ignoreMissing 就是在告诉 pnpm:"别警告,这俩由宿主环境在运行时提供"。顺带两条补充:
    • @types/react 的大版本要跟 Steam 客户端 CEF 里的 React 对齐(当前是 19.x),否则 hook 签名 / JSX 类型会在编译期就报错;
    • 包管理器建议锁定 pnpm@decky/rollupignoreMissing 规则都默认按 pnpm 的 hoist 行为设计,npm i / yarn 可能会把 react 拉成直接依赖一起打进 bundle,绕过 external。
  • @decky/ui 必须跟 Decky Loader 的版本同步:官方在 tasks 里专门准备了 updatefrontendlib 任务(即 pnpm update @decky/ui --latest),构建前一刻强制升级一次,避免把过期的类型定义带进商店审核。

前端运行时:src/、Rollup 与 definePlugin

构建:为什么是 Rollup 而不是 Vite

rollup.config.js 只有三行:

import deckyPlugin from "@decky/rollup";

export default deckyPlugin({
  // Add your extra Rollup options here
});

@decky/rollup 预置了插件需要的一切 —— TypeScript、JSX、资源处理(由 preset 内部的资源插件把 import 重写成 Decky 提供的本地资源 URL)、external React、以 format: "esm" 输出到 dist/index.js。Decky Loader 加载插件时,会把这个单文件读成字符串注入到 Steam CEF,所以:

  • 不要分包、不要动态 import():最终必须是一个文件;
  • 不要引入 Tailwind / CSS-in-JS 运行时:包体积会快速膨胀,而且可能和 Steam 原生样式冲突,更推荐直接在组件里内嵌 <style>{...}</style>
  • 资源由 preset 内置的资源处理插件接管:模板里给了一份 src/types.d.ts,声明 *.png*.svg*.jpg 为 string 模块。import logo from "../assets/logo.png" 拿到的不是 base64 data URL,而是由 preset 注入、指向 Decky 本地资源服务的相对 URL——运行时由 Loader 从插件目录里真实读取文件。这样既不会把图片塞进 bundle 撑大体积,也保留了缓存能力(具体插件名在 @decky/rollup 各版本间有变动,以实际 pnpm list 为准)。

tsconfig.json 开了 strictnoUnusedLocalsnoUnusedParameters 等一揽子严格选项,jsx: "react-jsx" 保证 JSX 编译到共享 React 运行时。新建插件时建议原样保留 —— Decky Loader 本身不强制,但严格模式能帮你避开大量运行时惊喜。

入口:definePlugin 的返回值就是插件

下面是基于模板 src/index.tsx精简改写版——删掉了原文件里注释掉的 router / logo 示例,把随机数范围从 Math.random() 换成 Math.floor(Math.random() * 100) 便于演示,骨架和 API 用法与模板一致:

// src/index.tsx
import {
  ButtonItem,
  PanelSection,
  PanelSectionRow,
  staticClasses,
} from "@decky/ui";
import {
  addEventListener,
  removeEventListener,
  callable,
  definePlugin,
  toaster,
} from "@decky/api";
import { useState } from "react";
import { FaShip } from "react-icons/fa";

// 前端 RPC 代理:对应 Python 端的 Plugin.add(left, right) -> int
const add = callable<[first: number, second: number], number>("add");
// 触发一个耗时 15s 的后端任务,完成后通过事件回传
const startTimer = callable<[], void>("start_timer");

/** 侧边栏面板的主体内容 */
function Content() {
  const [result, setResult] = useState<number | undefined>();

  /** 点击按钮时调用后端 add 并展示结果 */
  const onClick = async () => {
    const sum = await add(
      Math.floor(Math.random() * 100),
      Math.floor(Math.random() * 100),
    );
    setResult(sum);
  };

  return (
    <PanelSection title="Panel Section">
      <PanelSectionRow>
        <ButtonItem layout="below" onClick={onClick}>
          {result ?? "Add two numbers via Python"}
        </ButtonItem>
      </PanelSectionRow>
      <PanelSectionRow>
        <ButtonItem layout="below" onClick={() => startTimer()}>
          Start Python timer
        </ButtonItem>
      </PanelSectionRow>
    </PanelSection>
  );
}

export default definePlugin(() => {
  // 订阅后端通过 decky.emit 发出的事件
  const listener = addEventListener<[string, boolean, number]>(
    "timer_event",
    (a, b, c) => {
      toaster.toast({ title: "timer_event", body: `${a}, ${b}, ${c}` });
    },
  );

  return {
    name: "Test Plugin",
    titleView: <div className={staticClasses.Title}>Decky Example Plugin</div>,
    content: <Content />,
    icon: <FaShip />,
    onDismount() {
      // 插件可以热重载,必须在卸载时注销监听/路由/补丁
      removeEventListener("timer_event", listener);
    },
  };
});

关键心智模型:

  1. definePlugin(factory) 返回的对象就是插件的形状。最常用的四个字段:titleViewcontenticononDismount。如果你还要注册自定义路由,就在 factory 里调 routerHook.addRoute(...),并在 onDismount 里对应地 removeRoute
  2. 交互控件优先来自 @decky/uiPanelSectionPanelSectionRowButtonItemToggleFieldFocusableSidebarNavigation 等等。这些组件已经处理好了手柄聚焦、主题色跟随、与 Steam CSS 的兼容性。展示型 <div> 可以用,但可点击、可选择、可滚动的自定义元素要包进 Focusable,否则手柄模式下很容易失焦。
  3. 通信只有两种形态
    • 前端 → 后端callable<[Args], Ret>(name) 生成一个强类型 RPC 代理;
    • 后端 → 前端:Python 里 await decky.emit("event_name", ...),前端用 addEventListener 订阅。
  4. onDismount 是热重载的保命符。Decky Loader 允许在设置里单独重载某个插件,不清理监听会残留"幽灵事件",页面一刷新就会看到重复 toast。要意识到 Decky 的"热重载"只重启 Python 后端进程并重新注入前端 bundle,CEF 全局状态(window.*、定时器、React Portal)不会被清理——所以不光要 removeEventListener,凡是你挂到全局对象上的字段、注册的 setIntervalrouterHook.addRoute 的路由,全都要在 onDismount 里显式回收。

💡 语法细节:callable<[first: number, second: number], number>(...) 里的 [first: number, second: number] 是 TypeScript 4.0+ 引入的带标签元组类型,只影响 IDE 提示(参数名悬浮),不是 Decky 特殊 DSL,也不参与运行时。如果你觉得啰嗦,写成 callable<[number, number], number>(...) 完全等价。

后端运行时:main.pydecky.pyi 与目录约定

Python 入口

模板的 main.py 展示了后端的所有骨架:

# main.py
import os
import asyncio
import decky


class Plugin:
    """Decky Loader 通过反射加载这个固定名字的类。"""

    async def add(self, left: int, right: int) -> int:
        """简单的同步风格 RPC:返回两数之和。"""
        return left + right

    async def long_running(self):
        """演示:异步任务 + 通过事件向前端回传结果。"""
        await asyncio.sleep(15)
        await decky.emit("timer_event", "Hello from the backend!", True, 2)

    async def start_timer(self):
        """被前端通过 callable('start_timer') 触发。"""
        self.loop.create_task(self.long_running())

    async def _main(self):
        """插件进入时调用一次,适合做初始化/读配置。"""
        self.loop = asyncio.get_event_loop()
        decky.logger.info("Hello World!")

    async def _unload(self):
        """被停用/热重载时调用,清理资源但保留设置。"""
        decky.logger.info("Goodnight World!")

    async def _uninstall(self):
        """彻底卸载时调用,做最终清理。"""
        decky.logger.info("Goodbye World!")

    async def _migration(self):
        """迁移历史目录/配置;由 Loader 在 `_main` 之前自动调用一次。"""
        decky.migrate_logs(os.path.join(
            decky.DECKY_USER_HOME, ".config", "decky-template", "template.log"))
        decky.migrate_settings(
            os.path.join(decky.DECKY_HOME, "settings", "template.json"),
            os.path.join(decky.DECKY_USER_HOME, ".config", "decky-template"))
        decky.migrate_runtime(
            os.path.join(decky.DECKY_HOME, "template"),
            os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-template"))

提炼几条容易踩的坑:

  • 类名必须叫 Plugin,Decky Loader 通过字符串反射拿它,改了就起不来。
  • 所有对外方法都必须是 async,哪怕是同步操作。Decky Loader 会 await 每一次 RPC。
  • 方法参数和返回值必须是 JSON-safe(基本类型、dictlist)。想要类型提示就用 TypedDict
  • 生命周期钩子_main_unload_uninstall_migration 四个,命名固定、全部可选。其中 _migration 由 Decky Loader 在 _main 之前自动调用一次,不需要(也不应该)在 _main 里再手动调用。模板里把这些都写全了,可以作为"要不要支持这个行为"的 checklist。
  • _migration 的幂等原则:不要靠版本号,而是看目标字段/目录是否已经存在,用户可能跨多个版本升级。

decky 模块:一个"受约束的标准库"

模板里附带一份 decky.pyi —— 它是 Decky Loader 注入到 Python 进程里的 decky 模块的类型存根。读它等于读了一份后端能用的 API 清单。

📌 常量 vs 环境变量:下表中以 DECKY_ 开头的项同时以 decky.XXX 常量和 os.environ["XXX"] 环境变量两种形式存在。二者内容一致,但在你自己 subprocess.Popen 启动的子进程(例如 C/Rust 编出来的后端)里 只能 通过环境变量拿到——decky 模块不会被自动继承下去。

常量 / 函数 含义
decky.HOME / decky.USER 当前进程的 HOME 与用户名(受 _root 影响)
decky.DECKY_USER_HOME 真正的 deck 用户家目录,/home/deck
decky.DECKY_HOME ~/homebrew,Decky 自己的根目录
decky.DECKY_PLUGIN_DIR 当前插件解压后的根目录
decky.DECKY_PLUGIN_NAME 当前插件名,来自 plugin.json
decky.DECKY_PLUGIN_VERSION / DECKY_PLUGIN_AUTHOR 版本号、作者;上报遥测或日志时比硬编码好
decky.DECKY_VERSION Decky Loader 自身版本,做兼容性判断用
decky.DECKY_PLUGIN_SETTINGS_DIR 推荐写配置的位置,已由 loader 自动创建
decky.DECKY_PLUGIN_RUNTIME_DIR 推荐写运行时数据(缓存、临时文件)
decky.DECKY_PLUGIN_LOG_DIR 推荐写持久日志
decky.DECKY_PLUGIN_LOG 主日志文件路径
decky.logger 已绑定到上面日志文件的 logging.Logger
decky.emit(event, *args) 向前端推事件
decky.migrate_settings / _runtime / _logs 分别迁移配置/运行时/日志到约定目录
decky.migrate_any(target_dir, *sources) 上面三者的通用版:把任意旧路径搬到指定目标目录,用于不属于三类标准目录的数据

一条很关键的规则:不要往 DECKY_HOME 之外写任何东西。写 /etc/usr/local 这类路径即使拿到了 _root 也会被商店审核打回来,而且 SteamOS 下次更新会把只读分区整个覆盖掉。

带原生后端:backend/ 目录

如果你需要 C/C++/Rust/Go 编出的二进制(例如调用底层驱动),就把源码放进 backend/src/,再写一个 Makefile 把产物丢进 backend/out/。模板里的 backend/Makefile 简化到极致:

all: hello

hello:
mkdir -p ./out
gcc -o ./out/hello ./src/main.c

.PHONY: clean
clean:
rm -f hello

⚠️ 模板 backend/Makefileclean 规则与实际产物路径不一致rm -f hello 想删的是 backend/hello,但产物实际在 backend/out/hello——这条规则在模板里是个 no-op。套到实际项目时,改成 rm -rf ./out 或精确删除 ./out/<binary>

Dockerfile 使用官方提供的 holo 基础镜像(还有 holo-toolchain-rust / holo-toolchain-go 变体),entrypoint.sh 里只做一件事:cd /backend && make。Decky CLI 在构建插件时会 docker run 这个镜像,得到的 backend/out/* 会被拷贝到最终 zip 的 bin/ 下,插件运行时通过 os.path.join(decky.DECKY_PLUGIN_DIR, "bin", "hello") 调用。

这么做的好处是构建环境和 Steam Deck 完全一致,避免了"在 Ubuntu 编出来扔到 Deck 上找不到 glibc"的经典问题。

第三方依赖:py_modules/

SteamOS 的 /usr 是只读的,你没法 pip install 到系统 Python。社区约定的做法是:把第三方 Python 包 vendored 进 py_modules/,Decky Loader 会自动把这个目录加入 sys.path。模板里留了一个 .keep 占位,开发时你只需要 pip install --target=py_modules xxx 即可。

静态文件:defaults/

defaults/defaults.txt 的注释里说得很清楚:这个目录里的内容会被原样打进插件根目录。常见用途:默认 CSS 主题、种子配置、离线资源。注意它不能把文件铺到任意路径,只能放在插件目录内部。

开发工作流:.vscode/ 的一套"远程开发套件"

这是很多教程一笔带过、但对日常体验最友好的部分。模板的 .vscode/ 目录里是一套把"本地改代码"连接到"Steam Deck 上重载运行"的脚本。核心文件:

文件 作用
tasks.json 声明 VS Code 任务:setup / build / deploy / builddeploy / restartdecky
setup.sh 首次初始化:检测 pnpm、Docker,下载 Decky CLI
config.sh 校验是否已有 .vscode/settings.json,没有就复制 defsettings.json
build.sh 调用 Decky CLI 把当前目录打成符合商店规范的 zip
defsettings.json Deck 的 IP / 用户名 / 密码 / 插件名等默认值

首次设置

打开 VS Code 后运行 setup 任务,它会按顺序:

  1. 执行 setup.sh,检查 pnpm 与 Docker——Docker 只会检测是否存在并给出安装提示(不会替你装),pnpm / Decky CLI 则是辅助安装或下载缺失文件;
  2. 执行 pnpm i
  3. 执行 updatefrontendlib,把 @decky/ui 升到最新。

然后 config.sh 会拷贝 defsettings.json 生成 .vscode/settings.json

⚠️ 先看这里再复制:模板里的 deckpass: "ssap" 只是占位值,不要把真实密码写进生成的 .vscode/settings.json 再提交到仓库。推荐的做法是生成一对 SSH key(ssh-keygen 然后 ssh-copy-id deck@steamdeck.local),把 deckpass 留空,靠 deckkey 指定的私钥免密登录;部署脚本里的 sudo -S 几处确实还需要密码,但至少 ssh 本身不再依赖明文。模板 .gitignore 默认忽略了 .vscode/settings.json,但很多人会"一不小心" git add -f 上去——养成 git diff --cached 再提交的习惯。

{
    "deckip":     "steamdeck.local",
    "deckport":   "22",
    "deckuser":   "deck",
    "deckpass":   "",
    "deckkey":    "-i ${env:HOME}/.ssh/id_rsa",
    "deckdir":    "/home/deck",
    "pluginname": "Example Plugin",
    "python.analysis.extraPaths": ["./py_modules"]
}

把前几项改成你自己的 Deck 配置。首次连接前需要在桌面模式用 passwd 给 deck 用户设个密码(SteamOS 默认无密码),然后 ssh-copy-id 推公钥上去,之后就可以把 deckpass 清空了。

一条命令从代码到 Deck

build 任务会:

  1. 跑完上面的 setup + settingscheck

  2. 执行 build.sh,里头只有一行核心:

    sudo -E $CLI_LOCATION/decky plugin build $(pwd)
    

    Decky CLI 会读 plugin.json,跑 backend/Dockerfile 编原生后端,再把 dist/main.pyplugin.json 等打成 zip 塞进 out/

deploy 任务负责把 zip 传到 Deck:

  1. chmodplugins:在 Deck 上 chown 插件目录,避免 rsync 时因为只读报错;
  2. copyziprsyncout/*.zip 上传;
  3. extractzip:在 Deck 上 bsdtar -xzpf 解压到 ~/homebrew/plugins/<pluginname>/

组合任务 builddeploy 一键完成编译 + 上传 + 解压,再配上 restartdeckysudo systemctl restart plugin_loader)就完成了"改代码 → 一个快捷键 → Deck 上看效果"的闭环。

如果你不用 VS Code,其实只要直接调用 pnpm run build + Decky CLI + rsync 就能复刻同样的流程。整套脚本真正的价值在于把开发者常用的远程操作做成了自包含、幂等的 shell 脚本,可读性很高,推荐逐字读一遍。

打包与分发:插件 zip 的目录结构

上面 .vscode/ 那套脚本本质上就是 CI 流水线的"本地版"——跑的都是同一条 decky plugin build。搞懂本地产物长什么样,再把同一段 shell 搬到 GitHub Actions 里就是 CI。当你准备把插件交给用户或提交到 decky-plugin-database 时,zip 的结构是有严格约束的:

pluginname-v1.0.0.zip
└── pluginname/
    ├── bin/              (可选,原生后端的产物)
    │   └── <binary>
    ├── dist/
    │   └── index.js      (必需)
    ├── package.json      (必需)
    ├── plugin.json       (必需)
    ├── main.py           (必需,如果用了 Python 后端)
    ├── README.md         (建议)
    └── LICENSE(.md)      (提交商店时必需)

几条硬性规则:

  • LICENSE 随包分发:插件商店(decky-plugin-database)的 README 重点在于"如果许可证要求随源码/二进制一起分发,商店不会接受缺少许可证的提交"——换言之,是否必需取决于你选的许可证本身。官方 zip 目录结构列表把它标为 required,最保险的做法仍是把 LICENSE 放仓库根目录,由打包流程自动复制进 zip;
  • zip 内有且仅有一个同名顶层目录,Decky Loader 就是靠这个目录名识别插件;
  • dist/index.js 是唯一入口,所有前端代码都必须打进这一个文件;
  • bin/ 下的二进制要可执行,打包脚本会自动 chmod,但你本地 rsync 调试时得注意权限。

用户侧安装需要先在 Decky Loader 的设置里打开 Developer Mode,之后会多出两个安装入口:

  • Install Plugin from URL:粘贴一个指向 zip 的公开直链即可,Loader 会自行下载并解压。CI 产物最常见的做法是配合 nightly.link 暴露 GitHub Actions artifact,用户一行地址就能装上最新开发版;
  • Install Plugin from ZIP File:把本地 zip 丢进去,适合离线分发或内部测试。

如果非要手动处理文件,不是把 zip 原样丢进 ~/homebrew/plugins,而是把它解压成 ~/homebrew/plugins/<plugin-dir>/ 这种目录结构后再重启 loader。

提交商店前的最小自检

正式向 decky-plugin-database 提交 PR 前,建议过一遍这份 checklist,能挡住绝大多数一眼驳回:

  • zip 内的顶层目录名未与 decky-plugin-database 已收录的插件目录冲突;
  • 未启用 _root / root,或在 PR 描述里解释必要性;
  • LICENSE 文件随 zip 分发,且与仓库实际许可证一致;
  • CI 产物能通过 nightly.link 公开直链下载(方便审核者复现);
  • README 标明了支持的 Decky Loader 版本下限;
  • zip 内只有一个pluginname 一致的顶层目录,没有多余 dotfile(.DS_Store / .git/ / node_modules/)。

调试与排错

插件一旦跑起来就很容易"卡在某一层"——前端白屏、按钮点了没反应、后端一启动就崩。按照数据流向从上到下排查最省时间。

前端:CEF DevTools

Steam Deck 开启开发者模式后,Steam 客户端会把 CEF 的远程调试端口开在 http://<deck-ip>:8081(Decky Loader 自带的 scripts/deckdebug.sh 就是这么约定的)。用桌面 Chrome 访问这个地址,找到对应的 Steam UI 页面点进去,就是熟悉的 DevTools:断点、Console、Network、React DevTools 都能用。

几个高频场景:

  • callable(...) 调用没反应:在 DevTools 里 await 那个代理函数看返回值——后端抛异常时 callable 返回的 Promise 会 reject,必须 try/catch,否则 UI 只会静默失败;
  • addEventListener 收不到事件:事件名是字符串匹配,前后端拼写必须完全一致;同时确认后端的 decky.emit 是在 _main 之后被调用的,_main 之前 emit 会丢;
  • 白屏但没报错:多半是 definePlugin 的 factory 里同步抛了异常,Loader 只会静默跳过。把 factory 内容用 try/catch 包一层,错误写进 console.error

后端:日志与直接运行

后端的 print 会写到 Decky Loader 的主日志,混在所有插件输出里很难找。decky.logger 代替 print:它已经绑定到 DECKY_PLUGIN_LOG 指向的文件。需要注意的是,Decky Loader 每次启动插件时会按时间戳新开一份 .log 文件,DECKY_PLUGIN_LOG 常量指向的就是"本次启动的那一份"(而不是固定的 plugin.log),decky.logger 也写入这同一个文件。所以查看时要按修改时间排序拿最新一份。常用方式:

# 1. SSH 到 Deck 上,按修改时间挑最新一份 tail
ssh deck@steamdeck.local \
  "LOG=$(ls -t ~/homebrew/logs/<plugin-dir>/*.log | head -n1) && tail -f "$LOG""

# 2. Decky Loader 设置 → Developer → Plugin Logs 里点插件名

如果插件根本起不来(UI 侧边栏里看不到图标),走这个顺序:

  1. ~/homebrew/services/PluginLoader/PluginLoader.log,Loader 加载插件失败的 traceback 在这里;
  2. 本地先用 python3 -m py_compile main.py 做一次语法检查;真正的运行期问题(尤其是 import decky 立刻失败)不能ssh 到 Deck 上直接跑 python3 main.py 复现——decky 模块是 Loader 在启动插件进程时注入到 sys.modules 的,裸跑会立刻在 import 阶段就失败,误导排查。要么看 Loader 自身和插件日志,要么自己写一个 harness 预先把 decky 环境伪造进 sys.modules 再跑;
  3. sudo systemctl status plugin_loader 看 Loader 自身是否健康,偶尔 SteamOS 更新会把 service 搞挂。

常见"看起来很诡异"的故障

  • Steam 客户端更新后插件白屏:大概率是你劫持的内部 React 组件换了结构或 CSS 类名变了。先看 console.error,再用 React DevTools 对比 DOM 结构;长期方案是避开 afterPatch 深层注入,改用 @decky/ui 官方组件;
  • 改代码后 Deck 上没变化:检查 builddeploy 是否真的跑完、restartdecky 是否执行;有时候 rsync 被 Deck 上只读文件系统拦下来,表现为静默失败;
  • 原生后端 exec 报 Permission deniedbin/ 里的二进制丢了可执行位,chmod +x 或重新 builddeploy 一次。

模板没覆盖、但很快会遇到的事

读完这套模板,你已经具备一个可运行的"Hello World"。真正做产品化还有几个常见话题:

  1. 国际化:Decky 并没有官方 i18n 方案,社区做法是在 src/data/i18n/*.json 下放翻译,封装一个 t(key, fallback),第一次使用时读 window.LocalizationManager 拿当前 UI 语言。
  2. 持久化配置:官方早期插件普遍依赖一个叫 settings.py 的小库(可以从其它插件仓库里复制一份到 py_modules/settings.py),它把 JSON 配置落到 DECKY_PLUGIN_SETTINGS_DIR,两行代码搞定读写。
  3. 调用 Steam 内部 APISteamClient.* 是 Steam 客户端在 CEF 里挂的全局对象,能拿到游戏列表、启动参数、好友状态等。没有官方文档,类型定义主要散落在 @decky/ui 以及社区反向工程的仓库里,写的时候务必做 undefined 判断。
  4. 打补丁 / 注入 UI@decky/ui 导出了 afterPatchfindInReactTreefindModuleByExport 等工具,用来劫持 Steam 自己的 React 组件(例如在游戏右键菜单加一项)。这类代码对 Steam 客户端版本非常敏感,最好写好 try/catch 和 fallback,一次更新就可能失效。
  5. 调用原生二进制:想在 Python 后端里调 backend/out/ 编出的程序,用 asyncio.create_subprocess_execsubprocess.run 更合适——不阻塞事件循环,能 await proc.communicate() 拿 stdout/stderr;路径用 os.path.join(decky.DECKY_PLUGIN_DIR, "bin", "<name>") 拼,别写死。
  6. 长任务取消asyncio.create_task 返回的 Task 存起来,前端要中止时通过一个 cancel_* RPC 调 task.cancel();任务里用 try/except asyncio.CancelledError 做清理。
  7. 并发共享状态:多个前端 RPC 可能并发进来(手柄连点、多个面板同时打开)。改共享状态前套 asyncio.Lock,比事后 debug 竞态快得多。配置落盘同理,建议用 tempfile.NamedTemporaryFile 写完后 os.replace 原子替换,而不是直接 open(path, 'w')——Steam Deck 电量耗尽的一瞬间,json.dump 写了一半会留下一个损坏的配置文件,下次启动插件就直接炸了。
  8. CI 发布GitHub Actions + softprops/action-gh-release 是社区常用方案:push 到 main 打一个 artifact(可以用 nightly.link 给用户分发开发版),打 tag 时自动生成 Release 和 zip。

写在最后

从一个模板出发理解 Decky,其实就是记住这四层:

  1. plugin.jsonpackage.json 声明"我是谁";
  2. src/index.tsx + definePlugin 提供嵌入 Steam 的 UI;
  3. main.py + decky 模块提供受约束的后端能力;
  4. .vscode/backend/、Decky CLI 把开发到发布的流程串起来。

等这四层在你脑子里跑通了,就可以大胆扔掉模板、按自己的审美重组代码 —— 你做的事情本质上只是在 Steam 客户端里塞一个 React 组件,以及在 Deck 上跑一个 Python 小服务。

参考资源:

【节点】[Ceiling节点]原理解析与实际应用

作者 SmalBox
2026年5月6日 16:18

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity URP Shader Graph中,Ceiling节点是一个重要的数学运算节点,它执行向上取整操作。这个节点在着色器编程中具有广泛的应用场景,特别是在需要将连续值转换为离散整数值的情况下。Ceiling节点的功能类似于编程语言中的ceil()函数,它能够将输入的浮点数值向上舍入到最接近的整数。

Ceiling节点在Shader Graph中属于数学运算类别,它接受任意维度的矢量输入,并返回相同维度的矢量输出。这意味着它可以处理从单个浮点数到四维矢量的各种数据类型,为着色器开发提供了极大的灵活性。

理解Ceiling节点的工作原理和应用场景对于创建高质量的着色器效果至关重要。无论是创建像素化效果、实现网格对齐,还是进行数值离散化处理,Ceiling节点都能提供精确的数学运算支持。

描述

Ceiling节点的核心功能是执行向上取整运算。具体来说,对于输入的任意浮点数值,Ceiling节点会返回大于或等于该值的最小整数。这个操作在数学上称为"天花板函数",因为它总是将数值向上取整到最近的整数值。

向上取整操作与向下取整(Floor)和四舍五入(Round)操作有着明显的区别。向下取整总是向负无穷方向取整,而四舍五入则根据小数部分的值决定向上或向下取整。Ceiling节点的行为则始终向上取整,无论小数部分的大小如何。

在Shader Graph中,Ceiling节点能够处理各种数据类型的输入,包括:

  • 浮点数(Float)
  • 二维矢量(Vector2)
  • 三维矢量(Vector3)
  • 四维矢量(Vector4)

当输入是多维矢量时,Ceiling节点会对每个分量独立执行向上取整操作。例如,对于输入值(1.2, 2.7, 3.0, 4.9),Ceiling节点将返回(2.0, 3.0, 3.0, 5.0)。

Ceiling节点在图形渲染中有多种应用场景。在创建像素化效果时,可以使用Ceiling节点将连续的UV坐标转换为离散的网格坐标。在实现材质平铺时,Ceiling节点可以帮助确保纹理正确对齐。在光照计算中,Ceiling节点可以用于创建离散的光照级别,实现卡通风格的渲染效果。

需要注意的是,Ceiling节点的性能开销相对较小,因为它执行的是简单的数学运算。在大多数现代GPU上,向上取整操作都能高效执行,不会对渲染性能造成显著影响。

端口

Ceiling节点的端口设计简洁而高效,遵循了Shader Graph节点设计的一致性原则。节点包含一个输入端口和一个输出端口,两者都支持动态矢量类型,这意味着它们可以自动适应连接的数据类型。

输入端口

输入端口名为"In",是Ceiling节点接收数据的入口。这个端口具有以下特性:

  • 方向:输入
  • 类型:动态矢量
  • 功能描述:接收需要执行向上取整操作的值

输入端口支持的数据类型包括:

  • Float:单精度浮点数
  • Vector 2:包含两个浮点数的二维矢量
  • Vector 3:包含三个浮点数的三维矢量
  • Vector 4:包含四个浮点数的四维矢量

当连接不同维度的数据时,Shader Graph会自动进行类型转换和适配。例如,如果将一个浮点数连接到期望Vector4的端口,系统会自动将浮点数扩展为四个分量相同的Vector4。

输入值的范围没有特定限制,Ceiling节点可以处理任意大小的浮点数值,包括正数、负数和零。对于特殊值如无穷大和NaN(非数字),Ceiling节点的行为遵循IEEE浮点数标准的规定。

输出端口

输出端口名为"Out",是Ceiling节点返回计算结果的出口。这个端口具有以下特性:

  • 方向:输出
  • 类型:动态矢量
  • 功能描述:输出向上取整后的结果值

输出端口的数据类型始终与输入端口保持一致。如果输入是Vector3类型,输出也会是Vector3类型,其中每个分量都经过了独立的向上取整处理。

输出值的特性包括:

  • 所有输出分量都是整数值
  • 输出值大于或等于对应的输入值
  • 输出值与输入值的差小于1

在实际使用中,输出端口可以连接到其他节点的输入端口,形成复杂的着色器计算流水线。由于输出值是整数,它们特别适合用作纹理数组的索引、循环计数器或条件判断的基准值。

端口连接示例

理解端口之间的连接方式对于有效使用Ceiling节点至关重要。以下是一些常见的连接模式:

  • 将UV坐标连接到In端口,然后使用Out端口驱动纹理采样,可以创建像素化效果
  • 将世界空间位置连接到In端口,然后使用Out端口计算网格对齐,可以实现体素化渲染
  • 将时间变量连接到In端口,然后使用Out端口创建离散的时间间隔,可以实现帧动画效果

端口连接的灵活性使得Ceiling节点能够适应各种复杂的着色器需求,从简单的数学运算到复杂的视觉效果生成。

生成的代码示例

当在Shader Graph中使用Ceiling节点时,Unity会在生成的着色器代码中插入相应的函数调用。理解这些生成的代码对于调试和优化着色器非常重要。

以下是Ceiling节点生成的典型HLSL代码:

HLSL

void Unity_Ceiling_float4(float4 In, out float4 Out)
{
    Out = ceil(In);
}

这个函数接受一个float4类型的输入参数In,计算其向上取整值,并通过输出参数Out返回结果。函数内部调用了HLSL内置的ceil()函数,这是DirectX着色器语言标准的一部分。

代码结构分析

生成的代码遵循了Unity Shader Graph的标准模式:

  • 函数名采用Unity_前缀,后跟节点名称和数据类型
  • 输入参数使用值传递方式
  • 输出参数使用out关键字,表示通过引用返回结果
  • 函数体简洁明了,直接调用相应的数学函数

对于不同的输入数据类型,Unity会生成相应的函数变体:

HLSL

// 浮点数版本
void Unity_Ceiling_float(float In, out float Out)
{
    Out = ceil(In);
}

// 二维矢量版本
void Unity_Ceiling_float2(float2 In, out float2 Out)
{
    Out = ceil(In);
}

// 三维矢量版本
void Unity_Ceiling_float3(float3 In, out float3 Out)
{
    Out = ceil(In);
}

性能考虑

从生成的代码可以看出,Ceiling节点的执行效率很高:

  • 直接映射到GPU的硬件指令
  • 没有复杂的控制流或条件判断
  • 支持矢量化操作,能够并行处理多个分量

在大多数现代GPU架构上,ceil()函数通常能在单个时钟周期内完成,这使得Ceiling节点成为着色器中性价比很高的数学运算工具。

自定义实现

虽然Unity会自动生成Ceiling节点的代码,但了解其实现原理有助于在需要时创建自定义版本。以下是一个功能等效的自定义实现:

HLSL

float4 CustomCeiling(float4 value)
{
    return floor(value) + 1.0 - step(frac(value), 0.0);
}

这个实现使用了floor()函数和frac()函数来模拟向上取整的行为,虽然不如内置的ceil()函数高效,但展示了向上取整操作的数学原理。

理解生成的代码还有助于在不同渲染管线之间移植着色器。例如,如果要将使用Ceiling节点的着色器从URP迁移到HDRP,或者适配不同的图形API,了解底层的HLSL代码会大大简化迁移过程。

实际应用案例

Ceiling节点在实际着色器开发中有着广泛的应用。以下是一些具体的应用场景和实现方法,展示了如何充分利用Ceiling节点创建各种视觉效果。

像素化效果实现

像素化效果是Ceiling节点的经典应用场景。通过将连续的UV坐标离散化,可以创建出复古的像素艺术风格:

HLSL

// 创建像素化UV坐标
float2 pixelatedUV = ceil(uv * pixelCount) / pixelCount;

在这种应用中,Ceiling节点确保每个像素区域内的所有点都映射到同一个离散坐标,从而创建出清晰的像素边界。调整pixelCount参数可以控制像素化程度,数值越大,像素越细小。

网格对齐和瓦片处理

在创建瓦片地图或网格基础的效果时,Ceiling节点可以确保物体正确对齐到网格:

HLSL

// 将世界坐标对齐到网格
float3 gridAlignedPosition = ceil(worldPosition * gridSize) / gridSize;

这种方法特别适用于:

  • 体素游戏的渲染
  • 策略游戏的网格移动
  • 建筑可视化中的对齐显示

离散化动画控制

Ceiling节点可以用于创建离散的时间间隔,实现帧动画效果:

HLSL

// 创建离散化的时间索引
float frameIndex = ceil(time * framesPerSecond);

这种技术适用于:

  • 精灵动画(Sprite Animation)
  • 帧动画序列
  • 定时触发的特效

光照和阴影处理

在 stylized 渲染中,Ceiling节点可以用于创建离散的光照级别:

HLSL

// 创建离散化光照值
float discreteLighting = ceil(dot(N, L) * lightLevels) / lightLevels;

这种方法可以创建出卡通风格的光照效果,其中光照过渡是阶梯状的而不是平滑的。

高级数学运算

Ceiling节点还可以与其他数学节点结合,实现更复杂的数学运算:

HLSL

// 计算向上取整的除法
float ceilDivision = ceil(dividend / divisor);

// 创建数值分箱(Binning)
float binnedValue = ceil(value * binCount) / binCount;

这些高级应用展示了Ceiling节点在数值处理和数据分析方面的潜力。

最佳实践和注意事项

为了充分发挥Ceiling节点的潜力,同时避免常见的陷阱,以下是一些最佳实践和注意事项。

性能优化

虽然Ceiling节点本身性能开销很小,但在大规模使用时仍需注意:

  • 避免在片段着色器中过度使用复杂的Ceiling操作链
  • 考虑将计算转移到顶点着色器或计算着色器中
  • 利用矢量化操作,一次性处理多个分量

数值精度考虑

在使用Ceiling节点时,需要注意浮点数精度问题:

  • 对于极大或极小的数值,可能会出现精度损失
  • 在比较Ceiling结果时,使用适当的容差值
  • 注意不同GPU架构可能存在的精度差异

与其他节点的配合

Ceiling节点通常与其他数学节点配合使用:

  • 与Floor节点结合,可以创建数值范围限制
  • 与Frac节点结合,可以分离数值的整数和小数部分
  • 与Round节点结合,可以实现不同的取整策略

调试技巧

当Ceiling节点行为不符合预期时,可以采取以下调试措施:

  • 使用Position节点可视化输出结果
  • 通过Divide节点缩放输出值,使其在可视范围内
  • 使用Custom Function节点插入调试输出

平台兼容性

Ceiling节点在大多数平台上都有良好的兼容性,但仍需注意:

  • 确保目标平台支持所需的精度级别
  • 在移动平台上测试性能表现
  • 验证在不同图形API下的行为一致性

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

【节点】[Saturate节点]原理解析与实际应用

作者 SmalBox
2026年5月5日 23:33

【Unity Shader Graph 使用与特效实现】专栏-直达

Saturate节点是Unity Shader Graph中一个基础且重要的数学运算节点,它在图形编程和着色器开发中扮演着关键角色。该节点的核心功能是将输入值限制在0到1的范围内,确保输出值永远不会超出这个标准化区间。在实时渲染和图形处理中,这种钳制操作对于保证数值的有效性和防止渲染错误具有不可替代的作用。

Saturate节点的名称来源于色彩理论中的饱和度概念,但在着色器语境中,它更准确地描述了数值范围的限制过程。当输入值小于0时,节点输出0;当输入值大于1时,节点输出1;对于0到1之间的输入值,则保持原样输出。这种简单而强大的功能使得Saturate节点成为着色器编写中最常用的工具之一。

在Unity URP(Universal Render Pipeline)环境中,Saturate节点的应用尤为广泛。URP作为Unity的轻量级渲染管线,对性能优化有着较高要求,而Saturate节点的高效执行特性使其成为实现各种视觉效果的首选方案。无论是处理颜色值、计算光照强度,还是进行复杂的数学运算,Saturate节点都能确保数值始终处于安全范围内。

从技术实现角度看,Saturate节点对应着HLSL中的saturate()函数,这是一个在GPU级别高度优化的指令。现代图形硬件通常对saturate操作提供原生支持,这意味着使用Saturate节点几乎不会带来额外的性能开销。相比之下,使用条件语句手动实现相同的钳制功能往往会导致性能下降,因此Saturate节点不仅是功能上的选择,更是性能优化的最佳实践。

描述

Saturate节点的核心功能是执行数值钳制操作,具体表现为对输入值进行范围限制。当接收到任意数值输入时,节点会自动检查该值是否位于0到1的区间内。如果输入值已经在此范围内,节点会直接输出原始值;如果输入值小于0,节点会将其提升至0;如果输入值大于1,节点会将其降低至1。这种操作在数学上可以表示为:Out = max(0, min(1, In))。

在图形编程中,数值范围的标准化至关重要。许多着色器操作和纹理采样都假设输入值位于0到1之间,超出这个范围的数值可能导致不可预测的渲染结果,包括颜色失真、亮度异常甚至性能问题。Saturate节点通过强制数值标准化,确保了着色器计算的稳定性和一致性。

该节点的应用场景极为广泛。在颜色处理中,Saturate节点可以防止颜色值溢出,确保RGB分量始终处于有效范围内。在光照计算中,它可以限制光照强度,避免过度曝光或负光照的情况。在透明度混合中,它可以保证alpha值不会超出合理范围,防止渲染顺序错误。此外,在基于物理的渲染(PBR)流程中,Saturate节点常用于处理粗糙度、金属度等材质参数的中间计算结果。

从数学特性来看,Saturate操作具有以下几个重要性质:

  • 幂等性:对已经饱和的值再次应用Saturate不会改变结果
  • 单调性:如果输入值增加,输出值不会减少
  • 有界性:输出始终在[0,1]范围内
  • 连续性:在输入范围内操作是连续的

这些数学特性使得Saturate节点在复杂的着色器网络中能够提供可预测的行为,大大简化了调试和优化过程。

在性能方面,Saturate节点是Shader Graph中最轻量级的操作之一。由于对应着GPU的原生指令,它在各种硬件平台上都能高效运行,包括移动设备和低端图形卡。这使得开发者可以放心地在着色器中大量使用Saturate节点,而不必担心性能损耗。

端口

Saturate节点的端口设计体现了其灵活性和通用性。节点包含一个输入端口和一个输出端口,两者都支持动态矢量类型,这意味着它们可以处理各种维度的数据,从简单的浮点数到复杂的四维向量。

输入端口

输入端口标记为"In",是节点接收数据的入口。这个端口的设计具有以下特点:

  • 动态类型支持:输入端口能够自动适应连接的数据类型,包括float、float2、float3和float4。当连接标量值时,节点执行逐分量钳制;当连接矢量时,节点对每个分量独立执行钳制操作。
  • 数值范围无限制:输入端口接受任意范围的浮点数值,包括负数、零、正数,以及超出常规范围的极大或极小值。这种设计使得节点能够处理各种计算中间结果,无需预先对输入进行范围调整。
  • 自动类型转换:当输入类型与预期不符时,Shader Graph会自动进行合理的类型转换。例如,将整数转换为浮点数,或者通过复制分量来匹配维度要求。
  • 多数据流支持:输入端口可以连接来自各种源的數據,包括属性节点、纹理采样节点、数学运算节点,甚至是其他复杂着色器子图的输出。

输出端口

输出端口标记为"Out",是节点处理结果的出口。输出端口具有以下关键特性:

  • 类型一致性:输出类型始终与输入类型完全匹配。如果输入是float3,输出也是包含三个分量的float3,每个分量都独立经过钳制处理。
  • 数值保证:输出端口的每个分量都严格保证在[0,1]范围内,这是节点的核心承诺。开发者可以依赖这一特性来构建安全的着色器逻辑。
  • 下游兼容性:由于输出值被限制在标准化范围内,它可以安全地连接到任何期望0-1范围输入的节点,如颜色混合节点、透明度节点或纹理坐标节点。
  • 链式处理能力:输出端口可以连接到其他Saturate节点或其他数学运算节点,支持复杂的处理流水线。这种设计允许开发者在着色器图中构建多级数值安全机制。

端口交互示例

考虑一个典型的使用场景:计算漫反射光照。假设我们有一个光照强度值,可能因为各种计算而超出正常范围:

光照强度 = 基础光照 + 高光反射 + 环境光

通过将计算结果连接到Saturate节点的输入端口,可以确保最终的光照强度不会过度曝光(大于1)或产生负光照(小于0)。输出端口提供的安全值可以直接用于颜色计算,确保渲染结果的物理正确性。

在矢量处理方面,假设我们有一个float3类型的颜色值,其中某个分量可能因为计算错误而变为负值:

问题颜色 = float3(1.2, -0.3, 0.8)

通过Saturate节点处理后:

安全颜色 = float3(1.0, 0.0, 0.8)

这种自动修正机制防止了颜色异常,同时保持了有效分量的正确性。

生成的代码示例

Saturate节点在最终编译的着色器中会生成对应的HLSL代码。理解这些生成的代码有助于开发者优化着色器性能和调试复杂效果。以下是Saturate节点在不同情况下的代码生成示例及其详细解析。

基础浮点数钳制

当处理单个浮点数输入时,生成的代码最为简单:

void Unity_Saturate_float(float In, out float Out)
{
    Out = saturate(In);
}

这段代码定义了一个函数,接收浮点数输入In,通过HLSL内置的saturate()函数进行处理,然后将结果存储在输出参数Out中。在GPU级别,saturate()操作通常对应着一条原生指令,执行效率极高。

矢量类型处理

对于多维矢量的处理,Saturate节点会生成相应的矢量版本:

void Unity_Saturate_float2(float2 In, out float2 Out)
{
    Out = saturate(In);
}

void Unity_Saturate_float3(float3 In, out float3 Out)
{
    Out = saturate(In);
}

void Unity_Saturate_float4(float4 In, out float4 Out)
{
    Out = saturate(In);
}

这些函数展示了Saturate节点对矢量类型的支持。重要的是,saturate()函数在HLSL中对矢量类型执行逐分量操作,这意味着每个分量都会独立地进行钳制处理。这种操作在GPU上通常是并行执行的,不会带来额外的性能开销。

内联优化

在实际的着色器编译过程中,编译器可能会对Saturate节点进行内联优化。例如,当Saturate节点与其他操作连接时,生成的代码可能是这样的:

// 原始节点网络:Multiply -> Saturate -> Output
float3 originalColor = tex2D(_MainTex, uv).rgb;
float3 brightColor = originalColor * _Brightness;
float3 finalColor = saturate(brightColor);
return float4(finalColor, 1.0);

在这种情况下,编译器会将Saturate操作直接内联到计算流程中,而不是调用独立的函数。这种优化减少了函数调用开销,提高了执行效率。

复杂表达式中的Saturate

当Saturate节点参与复杂数学表达式时,生成的代码会反映其在节点网络中的具体位置:

// 对应一个复杂的颜色处理流程
void surf (Input IN, inout SurfaceOutputStandard o)
{
    float3 baseColor = tex2D(_MainTex, IN.uv_MainTex).rgb;
    float3 emissive = tex2D(_EmissiveTex, IN.uv_EmissiveTex).rgb;
    float intensity = _EmissiveIntensity;

    // Saturate确保混合权重在有效范围内
    float blendFactor = saturate(_BlendAmount);

    // 使用钳制后的值进行混合
    float3 combined = lerp(baseColor, emissive * intensity, blendFactor);

    // 最终颜色也需要钳制以确保有效性
    o.Albedo = saturate(combined);
}

这个例子展示了Saturate节点在复杂着色器中的两种典型用法:一是确保混合参数在有效范围内,二是保证最终输出值的安全性。

性能优化考虑

从生成的代码可以看出,Saturate节点的性能特性非常优秀:

  • 指令优化:saturate()通常编译为单个GPU指令
  • 寄存器效率:操作通常直接在寄存器中完成,不需要临时存储
  • 并行处理:矢量版本能够充分利用GPU的并行计算能力

相比之下,手动实现钳制功能往往效率较低:

// 不推荐的手动实现
float manualSaturate(float x)
{
    return max(0, min(1, x));
}

// 更差的条件语句实现
float badSaturate(float x)
{
    if (x < 0) return 0;
    if (x > 1) return 1;
    return x;
}

这些手动实现方式通常会产生更多的指令和分支,在GPU上的执行效率远低于原生的saturate()函数。

平台兼容性

生成的saturate()代码在所有支持HLSL的平台上都具有良好的兼容性:

  • DirectX平台:完全支持,从Shader Model 2.0开始就包含saturate指令
  • OpenGL/GLSL平台:Unity会自动将saturate()转换为clamp(),确保功能一致
  • 移动平台:无论是iOS的Metal还是Android的GLSL,都能正确转换和支持
  • 控制台平台:所有主流游戏主机平台都提供原生支持

这种跨平台一致性使得开发者可以放心使用Saturate节点,而不必担心平台兼容性问题。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

❌
❌