阅读视图

发现新文章,点击刷新页面。

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

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

缺氧和异星工厂的比较

缺氧(Oxygen Not Included, ONI)和异星工厂(Factorio)都是自动化领域的神作,它们在 Steam 上都有自动化、基地建设、资源管理的标签,可见游戏体验上有相当多的相似之处。玩家群体也高度重合。但是什么造就了它们的独特性?而不会像 Satisfactory 或戴森球计划之于异星工厂那样,有着深深的同类基因。

从游戏核心上看:

缺氧把玩家放在一个有限资源的环境下,一切都是资源转换。玩家玩游戏是一个熵治理过程,把无序变为有序。除了玩家的主动行为,还有丰富的、半随机的、环境自然推演。缺氧的物理系统在很大程度上模拟现实,这减少了玩家的学习成本。但在细节上和现实有所不同:质量和能量都不是守恒的,随着玩家活动,物质会减少,热量可以被主动删除…… 这给了许多玩家对付系统熵增的武器。而一个封闭系统中的热力学熵增,就是玩家需要对抗的系统崩塌。

异星工厂(原版)这提供给玩家一个无限地图。扩张面对的是物流和自动化的指数级复杂度上升。(在原版中)玩家不断追求更大更快的自动化生产线,在这个过程中,需要增进对游戏系统的理解,找到在下一个指数级上的自动化解决方案。玩家很少会面临系统崩溃,虫子的威胁虽然存在,但几乎可以忽略。即使去掉虫灾威胁,游戏也几乎不会损失太多体验。


两个游戏都强调物流。玩家都需要通过规划设计,在 2D 平面上把物品的需求和供给连接起来。

异星工厂机械爪和传送带是其经典元素。但为老玩家所熟知的还有液体管道、火车、无人机等都颇具游戏性。这些物流手段的差异在于吞吐量、延迟、控制复杂度、能量开销,在玩家优化物流时,需要在其中权衡。

缺氧很少鼓励玩家优化物流效率。它实际上也有无人机和管道运输两种基础物流,但却和异星工厂是反过来的。异星工厂先引入的是轨道/管道物流,无人机则在科技树后期开放;而缺氧一开局就给了最智能的“无人机”也就是仿生人,只需要指定需求点,仿生人就能自动的搬运物品。轨道运输反而需要仿生人的技能点足够了才能点开建造。清扫器(对应异星工厂的机械爪)也显得智能的多:只要在其作用覆盖范围内,就能自动的满足需求。大多数场景下,甚至不需要铺设轨道(对应异星工厂的传送带)。

缺氧因为单个地图都不太大(DLC 更是减小的母星的规模),所以单个地图上仿生人跑图的成本非常小。物质被存放在三个环境:地图格、散落在环境中的碎片以及工人设施中。物流手段大体上都可以看作是在这三个环境中的转换。例如,有的机器和环境交换流体,有的连接管道。所有的机器的固体产出都以碎片形式掉落在环境中。清扫器(爪子)可以在工人设施间传递物品,也可以将环境碎片放置到人工设施中。仿生人和挖矿机都可以把地图格上的固体转换为碎片,但仿生人只能把少量流体擦拭为碎片,无法直接转换地图格中的气体。液体和气体碎片必须通过设施投放到地图格,固体则需要依赖物理规则和温度变换才能在地图格结块。

而在费人工环境下,物质转移遵循的是系统的物理法则;仅有人工环境才可以通过轨道转移物品。可见,无论是固体轨道还是流体管道,在缺氧中只是大系统中的一个小部分(而不像异星工厂那样是核心系统)。缺氧的轨道更多为了解决封闭环境问题:缺氧需要玩家维持局部生产环境,真空、特定气体、高温、低温。而仿生人虽然是一个相当智能的物流工具,却会因为其活动感染环境。轨道则可以解决环境封闭后的物流。

和异星工厂颇为不同的是,缺氧里用仿生人做物流的效率大大超过轨道运输,速度更快,吞吐量更高。比如你用装罐器在 A 处把液体或气体装罐,再在 B 处卸载。物流效率上甚至超过铺设专门管道。其实,在异星工厂中也有类似的设计:你用背包装满物资,人肉运输和装填,效率或许是最高的。只不过无法自动化而不可能在大规模生产活动中持久使用。

缺氧中的轨道多用于在不适合仿生人活动的环境中使用,或是用于热交换。

热是缺氧中最特别的东西。它无法通过物流手段直接搬运,只能通过同一格或相邻格之间的热传导。将一个格子或一个东西控制在某个温度范围却是游戏后期的普遍挑战,这是造成缺氧和其它游戏差异的重要游戏元素。


两个游戏都有采矿,冶炼、制造设施、建设基地的元素。它们都可以用一系列基础设施组合起来构建更复杂的模块。比如在异星工厂中,玩家可以设计搭建不同的核电站、炼钢车间;缺氧中也可以设计不同的动物养殖场等等。高级玩家都是以功能模块为单位设计基地的。

缺氧模块的元素比异星工厂要更细粒度。从游戏设定上来说,缺氧本质上只有四种模块的建设材料:矿石、金属矿石、精炼金属和人工材料。虽然看起来玩家还是要先把这些基础材料建设为人工设施,再用设施搭建模块。但因为建设好的设施还可以无损的分解回材料碎片,所以也可以看作是用这四种基础材料搭建模块的。而异星工厂则是把原材料在生产流水线中经过若干工序的加工为机器,再将机器铺在地板上。机器无法(通过游戏的基础机制)还原为材料。

两者对比,可见缺氧在用更少更基本的元素搭建复杂模块。缺氧很多时候需要用多个设施组合起来才有基本功能。比如用一个管道元素信号器加上一个流体截断器可以拼装出一个流体分流器,它依赖的是更底层的流体在管道中流动的规则。异星工厂中也有类似的东西,例如用多个二分器和传送带一起构建一个更复杂的分流器。但两个游戏的差别在于,异星工厂的原件组合的规则更直白一些,更多的场景是组合的规模;而缺氧往往用几个原件在相对隐晦的规则下拼装在一起,并有着说不清的边界条件。

由于缺氧中搭建模块必须靠仿生人行动,所以建设次序有时也很关键。比如说斜角方块可以在不破环封闭性的前提下挖开或修建,如果破坏了封闭性,可能会破坏环境的封闭性,增加不必要的后续补救操作。不像异星工厂,需要解决指数级增加的生产需求,游戏本身提供了蓝图支持。缺氧中大部分模块在同一局游戏中只用建设一次,熟练的玩家不仅要记住一个模块的样子,还要注意建设次序,或是根据环境以及不小心犯下的小错误而动态调整。缺氧中的模块从尺寸上来说要比异星工厂要小,但它提供了更多的分层:除了功能层外,电线、液体、气体、轨道、信号在不同层上,这让它在更小的网格区域提供了更多的复杂度空间。

异星工厂改建模块的成本是相对较小的。建设、拆建、升级,有了蓝图和无人机的帮助,只是下达一个指令,在宏观上管理即可。这给游戏在规模上递进提供了玩法基础。缺氧的改建成本有时会变得很大。从游戏设定上看,有些设施拆除的时间成本就建设的成本高。环境的破坏成本也远低于建设成本:典型的例子就是抽真空是个及其费时的过程,而挖开一个砖块就可能打破真空环境。这也导致了新手在把基地建得一团糟后,重开一盘往往是更好的选择。虽然异星工厂也有类似体验(没建好就重开),但重建基地要容易的多。


异星工厂中,机器只要通电解决好供给和输出,就可以无限运转下去。建设基地可以人工快速铺建,也可以通过建设无人机蓝图铺设。玩家在不断的解决自动化的后勤工作。这样设定的前提是:资源接近无限,需要提升的是生产模块,以并行处理来提升生产效率。

缺氧则通过指派任务来进行游戏中的活动。相当多的机器还需要仿生人操作。运行机器也是任务的一部分,和建设及物流并列。

虽然 Rimworld 在这方面类似,都是间接的像小人下达任务指令,但我认为在内核上缺氧还是更接近异星工厂一点。你甚至可以把仿生人看成是异星工厂中无人机的加强版本,几乎不会有人会对缺氧中的仿生人共情。而 rimworld 中的小人却有更多的“人”性。但和异星工厂相比,缺氧一个显著的不同点:缺氧中的任务是需要分配优先级的。这是因为下任务太容易,很容易堆积下永远无法完成的任务清单。但任务间隐藏的依赖关联,游戏的底层规则未帮你自动理清。

让制定优先级成本核心操作的基础是:资源有限,任务有限,单个任务的步骤繁多并有隐藏的依赖关联。


异星工厂是靠研发科技推动游戏进程。从游戏设计上来看,可以明显感受到科技瓶生产本后需要的产能级数上升。玩家可以感受到爬科技树推动着游戏过程。未解开的科技驱动着玩家增加生产率,解开的科技则引导玩家通过新科技升级生产过程。在(原版)通关后,还可以追求发火箭的速度,在更高效率产能的需求推动下,寻找更大模块生产的策略,同时,那些可以无限循环的科技也能进一步的促进生产效率。

缺氧的科技树很早就能完成,玩家从新手成长起来,更多的是不断增进对那些科技解锁的设施的理解。游戏的绝大部分时间,科研都不太阻挡游戏进程。在一盘游戏中,玩家要做的工程总数并不多:建设供氧,解决食物来源,发电,删减累积的热量,获取火箭燃料,取得太空材料,制造终极火箭。这些项目并没有直接写在任务书中,也不存在于科技树上,而是在玩家玩的过程中,通过发现环境的改变,需要面临新的挑战而激发出来。对比异星工厂那个写在科技树顶端的科研项目,缺氧中促使玩家发展的暗线是材料。获取更高熔点的金属,在极低温度下也能保持液态的制冷剂,这些都可以在更宽的温度环境下用老方法获得新东西,直到最终制备出液氢火箭探索时空裂隙。

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

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

一个简单的缺氧地热模块

又玩了半个月的缺氧,目前累积游戏时间已达 645 小时,感觉对这个游戏有了更多理解。

我重新开了一盘,尝试用纯仿生人开局,依旧是保持 3 个初始小人开荒。仿生人不用吃食物,三个周期集中做一次呼吸,只要解决了能源问题,玩起来还是很舒服的。但仿生人需要定期上油,基本的方案是用排泄的残渣油做成润滑膏自循环。但我在查配方表的时候发现:润滑膏其实可以裂解成石油,而润滑则可以用菌泥榨成植物油替代。这样,似乎就多了一种方法在游戏初期拿到石油。

在我的理解中,缺氧的石油在开荒期的主要用途是做精炼金属的冷却剂,配合蒸汽机可以做到净增的电能。和现实常识不同,缺氧的世界是不遵守能量守恒的。金属精炼机消耗固定的电能功率,同时把金属熔解需要的热传递到冷却剂中。也就是输入固定的电能,输出不确定的热能。而蒸汽机则可将热转换为电。这导致,精炼某些金属,比如游戏开荒期急缺的钢,不仅不消耗电,还能产电。

但蒸汽机有个使用门槛:它只能将 125~200 度的蒸汽转换为 95 度的水。所以只有当精炼金属的冷却剂的温度提升到 125 度以上时,才可以用来发电。用水做冷却剂显然不行,因为水的沸点只有 100 度,冷却剂会气化,导致冷却管损坏。而石油是游戏初期最为常用的精炼冷却剂:它在 540 度左右才会发生相变。只要用石油做冷却剂炼钢,在炼钢的过程中会获得大量熔解铁的热,温度升到 200 度,然后送去蒸汽机还原为水,就能发电了,发的电将超过运行精炼机的电开销。

对大部分玩家来说,用石油冷却炼钢,通常是在游戏中学会的第一个主动热量控制的玩法。如果没有做足功课(比如看老玩家的攻略)自己研究缺氧的话,在面多了加水,水多了合面的试错过程中,一般会在基地变成蒸笼后第一次思考热控制的问题:看起来游戏中大部分的生产活动,哪怕只是小人闲逛,都是电能或生物能想热的单向转换。而游戏中提供的字面意义上的降温机器,似乎都在转移热量,而不是消除热量。这导致了整个基地的热会缓慢的上升,直到不适合生存。

初看蒸汽机需要在 125 度以上蒸汽的高温环境才能工作,却可以用来消除热,有点反直觉,细想又些合理之处。这就是游戏提供的挑战:先把温度(通过某种热交换)升上去,再用蒸汽机降下来,从中删除热。至于输出的 95 度的水依然温度很高,但绝对热量减少了。接下来,可以再利用冷凝机,消耗电制造温差降温(但不删除热),把温度继续降低到常温。这个过程也和现实不同,不遵守能量守恒:制造温差是需要输入电能的。

缺氧玩下去,直到通关。一直在挑战玩家,怎么获得极低的低温,如何掌控极高的高温。


和异星工厂这样的游戏不同。缺氧里没有直接的给出一个机器,造出来使用就可以制造低温材料或高温材料,也不是去到更远的地图上就能获得。这个限制在于大部分的机器都有一个工作温度,通常在常温附近,远低于制造机器材料的熔点。所以根本没法在高温环境使用它们。但仔细推敲游戏中的设施说明,似乎有一些端倪,比如碳炉没有工作温度限制,可以不断的炼碳堆积热量,似乎只要在一个密闭空间往里面塞材料,就能无限升温直到碳炉达到自身的融点。

看起来只要找到极高熔点材料来做碳炉就可以获得超高温环境了(实际操作却没那么简单)。

又比如,金属精炼机会把熔炼金属的热量输出到冷却剂中,而不是堆积在机器上。这使得它可以在室温下熔炼上千度的金属。找到合适的冷却剂就能把冷却剂加热到极高温度……

冷凝机也是如此:它可以无限把输入液体降温,只不过将热输出到自身的环境中。只要能找到凝固点极低的液体材料。事实上,缺氧的通关挑战就是制备出液态氢做燃料驱动终极火箭探索时空裂隙。

其实,不仅仅是高级材料。游戏中海暗藏了诸多并非依赖特定材料的温控挑战。一般来说,更复杂的方法都意味着生产效率。

缺氧的世界里,资源是有限的,玩家能做的大部分活动,都是在转换材料,而不是生成新的。而且在转换过程中,不仅不遵守质量守恒(表面上大体遵守),还伴随着质量损失。冶炼是将矿石变成金属、种植是将泥土变成农作物、烹饪是将有机物变成食物、进食是将食物变成废水……如果游戏世界里不存在火山和流星雨的话,游戏一开始看到的由海量砖块构成的整个世界终将变成虚无。更高级的转换方法意味着更少的损耗,不光是原料,还有生产过程中花掉能量对应的损耗。

以石油为例,如果只是查阅游戏手册的话,会发现最直接的制造方法:从原油精炼。但这个过程只有 50% 质量的原油转换为石油,平排放少量天然气。btw, 这质量减少或许对消除热反而是件好事。虽然原油精炼机本身会产热,但由于质量衰减,一些原油中的热被同时删除了。

但是,如果你能有办法把原油加热到 402 度的话,它们会 1:1 相变为石油。相当于产能提高了一倍。而且没有排放到环境中的天然气这种副产品,不需要做额外的气体管理。石油精炼机需要小人操作,但通常 400 度高温,想用一般人员操作的机器去做也办不到,只要找到方法,还可以节省劳动力。

由于采原油需要消耗水,而石油发电则可以产生废水。当原油和石油的转换比例为 2:1 时,用原油精炼石油发电的过程,水时亏损的;但如果有办法将转换比例提高到 1:1 ,水反而有正收益。这相当于,采集原油不仅不耗电耗水,还可以用来发电产水。

人工产生高热的方法有很多,比如上面提到的烧窑炉,还有熔炼玻璃(玻璃熔炉可以产出高达近 2000 度的熔融玻璃),但想利用它们却不简单。最容易想到的方法其实是利用地图底层的岩浆,它们有 1600 度的高温,只需要把热抽出来即可。这就是所谓的地热裂解石油。


我在最近的一盘新游戏中,倒没有在开荒期裂解石油的想法,因为油井要在第二颗星球上才有。我想的是怎样更早获得第一箱石油用于炼钢。仿生人的排泄物残渣油和原油一样,可以在高温下相变为石油,只是问题稍高一点,需要 450 度,但制备方法也是非地热无它。所以,我就尝试了在游戏开荒期开发地热。由于是开荒期,能动用的材料和人力都很有限,玩了两天后,我感觉对游戏的热交换机制有了一些新理解。在推特上和玩友进一步讨论后,索性在沙盒模式下测试了一些新想法。

如果在网上搜攻略,会发现地热开发都需要建设一个不算小规模的模块。通常,用金属砖或砖石砖把岩浆块中的热向上导引到高处,然后用一个金属气闸和模块连接并密封。这个金属气闸就是热桥。当用信号开启时,密封环境下的金属气闸打开会形成真空,而在缺氧的设定中没有热辐射,真空是完全隔热的;当金属气闸关闭时,金属材料会高效的传热。

导入的热可以轻松的把原油加热到 400 度 (或残渣油需要的 450 度),直接无损变为石油。

但上面制备出石油只是最简单的一步,难的是如何将高温石油降温利用。一般攻略里会教你做逆流换热:让低温原油接触高温石油,在原油加热的同时,石油也降温了。这通常做一个置顶向下的之字形通道,让高温石油自行流下,而低温原油在管道中逆流向上,一路和石油换温。等原油抵达加热处(顶上引入的岩浆高温块),预热的差不多了,只差临门一脚;石油流淌到最下方,也降低到可以接受的温度。

这里需要注意的是裂解时的温度,如果超过 540 度会继续相变为高硫天然气。所以一般会额外做一个热容器让裂解时温度变化稳定一点。


为什么网上找到的几乎都是逆流换热这个方法?我思考了一下,这是游戏机制决定的策略。

游戏中大部分机器的工作温度上限是 75 度,如果用金汞齐材料可以提高 50 度,用钢可以提高 200 度(后期还有更好的材料,但需要到游戏晚期);所以,125 /275 度几乎就是用机器操作的温度上限。超过 275 度后,必须利用游戏物理规则,而不是机器来控制世界中的物质。

在缺氧中,物质存在于三种状态,环境中的砖块、环境中的碎片、单位的附属物品。环境中,每个格子都只能存在一种物质砖块,或是固态或是液态或是气态或是真空,砖块会依据游戏的物理法则在环境中运动。固体几乎不能动;液体会流动,并受重力影响下落,气体会扩散,并据摩尔质量而分层。当玩家去影响这些砖块,可以把这个砖块变成碎片掉落在所在格,再可以拾取带在身上或放入机器中变为物品。

一旦砖块变为碎片再转变为物品,玩家就可以利用各种手段在游戏世界中移动它们。但温度就是它们之间的门槛。超过了机器的工作温度,无法用机械手段处理它们:不能把固体放进运输轨道、流体塞入管道。比如,当你想对超过 275 度的环境物体进行降温,不能拿起它们放在一台可以降温的机器中,而是需要把你可以控制的低温物品用管道送去它的同一格,依靠游戏物理法则交换温度:低温物体升温,高温物体降温。

那么,是否可以把原油转变为 400 度的石油就放在那里,静候它降温呢?理论上,它们最终会和环境保持一致的温度。但这个过程时间很长。缺氧中的自然运动都非常缓慢。但这还不是重点,重点是如果你把常温的原油变成了高温石油,等于往你的活动区域注入了相当大的热量。如果你不消除这些热量,最终都会反映为活动环境的温度上升。石油质量不小,300 度的温差意味着大量的热量。1kg 的原油,300 度温差大约是 50 万 DTU 。而一台蒸汽机全速工作,每秒大约能删除 80 万 DTU 。

这就是为什么,必须用升温得到的石油去加热常温的原油。只有这些,带入环境的热才相对更少。假设你引入地热把原油提升 300 度变为石油,再把这额外的 300 度温差转移到下一批同等质量的原油上,那么除了第一批原油,后面你就没有引入额外的热量。但不引入额外的工作,无法做到 100% 的热量转移,所以最终还需要一点点的主动散热才能做到热量平衡。


我这几天反复在沙盒中尝试的是:能不能做到一个较小且简单的模块做到把原油升温,再对其降温,做到热平衡。根据计算,光靠蒸汽机肯定是不行的。游戏中不设工作温度的工具实在有限,但因为限制,反而可以在有限的选择中尝试。

典型的是气闸门,可以随便用信号开关。前面已经提到气闸门可以用来做热桥,多个气闸门串联似乎还可以用来推动流体。但我不太想构建过于复杂的自动化机构。

流体容器和泵都有工作温度,这意味着无法把高温流体塞进管道控制流向。即使在低温段进入管道,当温度上升后,也无法做限流等控制,唯一例外的排出口,即从管道系统中排放到环境中是不受温度限制的。

直接在室温和 400 度高温之前控制不太可行,我们需要的是温度的梯度变化控制。275 度到 450 度之间完全不能主动操控,125 到 275 之前可以有限操控,125 度以下可以随意控制。

每次控制的质量多寡是有差别的。一次处理的质量很少时,温度变化很快,需要小心不要越过相变点;一次处理的质量很多时,需要累积很长的时间,长周期往往意味着环境会累积很多不可预期的变化。尤其时流体,对环境中的大质量砖块花很长时间升温或降温,就必须考虑这段时间它的流动行为。


如果只是想处理第一点点油,不考虑热平衡的问题,可以把地热引出来,在旁边放个小水库自然降温。虽然热进入了活动环境(水库温度上升了),但量少可以接受。这就好比游戏开荒期,都是抱着一个小水库炼第一批钢的。关键的技术点是怎么引出岩浆中的热。

ONI_1.png

这是我的方案,和网上常见的攻略不同,我用的是一些更早期的材料,适合在非常早期快速启动。在图片的红框处用了一点小技巧,那么个被碳掩埋的温度传感器。只需要挖开一个空格,在上面做一个温度传感器,并用煤做一个变温板。因为煤在 200 多度就会相变为精炼碳,所以就坍塌为一个碳方块,但温度传感器被固体掩埋后可以继续工作。用这个方法下面接上气闸门做的热桥和金属方块(图中是金属变温板)就可以非常安全精确的导入地热了。

因为这里我们需要的温度不超过 500 度,它们的材料用铜制就可以。接触岩浆的部分,虽然岩浆有 1600 度(邻接岩浆的深渊晶石 1300 度),但气闸门连续导热时间不会太长,实测最高温度在 1000 度之下,用铁(而不是钢)也是安全的。但铜的熔点太低,不可以使用,这包括连接闸门的信号线。注意:在热传导中,信号线/电线/管道等的导热是不可忽视的,不能光关注砖块。

这个建议地热装置把导热块设定在 400 或 450 度,超过就打开气闸门(关闭热桥),导入的环境的最高温度不会超过设定温度之上 20 度(算上开关热桥本身的时间差),由于自然换热,方块之前会趋向热平衡,方块不可能自然升温超过最高温方块。这个温度传感器得到的温度,是整个系统中的理论最高温度,所以这个依赖这个温度信号的机构是绝对安全的(石油因为超过温度而气化)。

从上面滴入原油,靠环境加热到相变温度后,会变成石油,继续滴入原油,两者需要占据两个格子,所以液面会提升。最终被挤上去,落入右侧的自然降温区,靠右边的水池降温。

除了下面的热桥,需要自动化控制的是上面的滴落口。如果一次加的原油太多,来不及变成石油。这里的方法是在低落点检测元素,发现是石油的话就继续滴。因为原油的密度比石油大,所以滴下来会沉在最下面和导热块直接接触,并把之前裂解的石油挤上去。推友提示说,石油和原油密度不同,单个格子石油质量有个上限,所以这里用液压传感器也可以(原油可以制造的最大液压超过石油)。但我尝试过之后,发现液压控制要求一次处理太多原油(单格装满),这样单个制备周期过长。


为了长期运行,我设计了一个带自身热平衡的模块。和网上传统的模块比,显然规模要小得多。我已经在沙盒中稳定的运行了 100 多周期。

ONI_2.jpg

它本质上也做了一个逆流换热机构,只不过不是用得自然流淌的通道,而是让低温原油在管道中绕圈做自循环。

低温原油先绕进右下的黄色区域,这个区域也是石油的最终出口,保持温度在 120 ~ 170 度左右,原油会对其降温,并将自己预热,部分回流到左上的初始区。长期运行的结果会将左上角的原油初始区温度提升到 120 度左右(所以这里的泵需要至少金汞齐)。

黄色区上面有一个主动降温区(绿色框),和黄色区用金属砖接触,用蒸汽机降温,同时可以稳定输出一定的电力。由于蒸汽室最低温度会停在 125 度(蒸汽机停止工作,也就不会输出 95 度的水继续降温),所以黄色区域在长期运行的温度下限就是 125 度。在初期由于有低温原油降温,温度会更低。如果持续有常温原油输入的话,黄色区会降低到一个更低的温度。但为了安全,黄色区的水泵还是钢制比较妥当(稍微超过了金汞齐的上限)。我在的设定是 130 度以下就把石油抽出。

橙色区是中温区,温度在 170 ~ 250 度左右。和黄色区用气闸门隔开,制造出温差。它的区域不大,只有 2x4 格,里面放了一圈自循环的管道。原油进入后,会不断循环升温,直到红色区域放行进入。

红色区域下面和地热源接触,本身的空间更小。更小的空间保证了一次加热的高温油不会太多。依然是一个 2x4 的管道死循环。这里设定管道温度达到 400 度的相变温度才从上方滴落。当然,在启动阶段,如果红色区域为空(液压检测),也允许滴入。这让红色区域的温度大部分时间都接近 400 度,滴落的液体瞬间就变成石油。当红色区域超压就会开门放入橙色区。同时,从原油从橙色区的自循环管线进入,原来黄色区管道中循环的液体进入橙色区,橙色区管道中循环的液体进入红色区。管道系统的三个循环圈逐级升温。

绿色框的主动降温区用了一个冷凝机额外对蒸汽机降温(使用上面那个独立水箱做冷却液)。它的工作时间不长,所以蒸汽机本身的发电就够用了。蒸气室内的温度几乎不会超过 170 度(因为最高温受红色区的高温限制),其实用金汞齐做冷凝机也可以。但我试运行时还是用了钢。

这个模块中几乎没用什么自动化信号,就是简单的用温控气闸门,以及最终的水泵。因为只靠温度控制,所以可以看到模块中混杂了石油和原油。这是因为没有设计额外的机制保证原油不从 A 区漏出。但这关系不大,在黄色区抽出的时候过滤一下即可(绿色区右侧有一个过滤器)。原油重新回到循环中是无害的。

它只所以不需要复杂的自动化控制,是因为原理很简单:

低温原油先在低温区转圈,把原油加热到低温区的平均温度。如果中温区有空位,就进入中温区,否则一直循环;同理,高温区没有空位,原油就在中温区循环;高温区会在管道中把原油温度加热到和环境一样的温度。环境中是原油和石油的混合液体,由地热砖持续加热。地热砖达到上限温度(我设为 500 度)就断开热桥,严格保证高温区的温度上限。

当(很小的)高温区装满时,打开闸门,将部分液体放入中温区,同时由于有了空位,中温区的原油管道进入高温区。

中温区和低温区的闸门由两个区域的温度同时决定。如果中温区温度不足(高温区的高温石油流入不足),或低温区温度太高(主动降温还不充分),它们是隔开的。这保证了中温区和低温区的温差,降低了用蒸汽机的压力。闸门的控制温度是反复实验的经验数字,衡量标准是让蒸汽机 90% 以上的时间都在持续输出电力,又来得及把最终石油的温度控制在 130 度左右。


附:水管接得很混乱,如果设计一下应该可以规划得更好。

ONI_3.jpg

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

面向第一次接触 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节点]原理解析与实际应用

【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节点]原理解析与实际应用

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

十年等一回!Steam 手柄再相见

在官宣 Steam Machine、Controller 和 Frame 足足半年之后,我们终于从 V 社那里得到了一个好消息:

虽然 Steam Machine 依然在难产,但是新款 Steam Controller 手柄终于上市,预计 5 月 4 日开售,价格 99 美元(约合人民币 680 元)。

图|Steam

在内存海啸之下,V 社最终还是选择暂缓了游戏主机与 VR 头显的出货,先用手柄试试水温。

此时,距离初代 Steam Controller 发布的 2015 年已经过去了近 11 年。

 

图|Steam

Valve 没有将新手柄叫做 Controller 2,而是以一种「重新开始」的姿态延续了最初的命名。

那新的 Steam 手柄能够支撑起这个系列定型的名字吗?结合先行评测的反馈来看,还真可以。

足够好用的鼠标

和十一年前的初代 Steam 手柄一样,2025 款新手柄的主要特色,依然是那两块与摇杆同等重要的触控板。

图|TheVerge

这背后的产品逻辑很简单:

Steam 作为毋庸置疑的全球最大游戏平台,Windows 游戏始终是其中的「压舱石」。

而 Valve 要想经营好 SteamOS 生态,就必须解决那个手柄的老大难问题——鼠标兼容。

而在充分吸取 2015 年初代 Steam 手柄、2022 年 Steam Deck 的经验之后,V 社的确在新手柄上拿出了一套体验更优秀的鼠标模拟体验。

根据官方参数,新 Steam 手柄的触控板变长为 34.5mm,相比 Steam Deck 的 32.5mm 略微增大,并根据手柄的握持特点微微外倾:

图|YouTube @LinusTechTips

根据加拿大白嫖王 Linus 的评价:「这就是目前市面上最好的鼠标模拟体验」。

相比初代手柄的圆形触控板,新触控板的触感反馈非常紧凑、强大,舒适程度甚至远超初代手柄和 Steam Deck。

毕竟从 V 社的设计角度出发,无论手柄、主机还是整体的 SteamOS,这套软硬件生态是服务于「连接卧室里的游戏 PC 与客厅电视的桥梁」体验的。

游戏媒体 Digital Foundry 也在评测中给出了近似的赞扬,指出:

新版手柄的设计逻辑完整沿袭 Steam Deck,相比初代手柄上的触控板,新版的上手门槛更低、操作更可靠。

图|YouTube @DigitalFoundry

在实际游戏体验中,这两块触控板也没有辜负 Valve 用心的优化。

首先是兼容性,新版 Steam 手柄支持高度的 Steam Input 自定义能力,无论是官方还是社区的配置文件,几乎都可以做到无缝衔接、边玩边换。

再搭配内置的陀螺仪瞄准功能,Steam 手柄在 FPS 游戏中哪怕不使用辅助瞄准,也可以「拥有近乎开挂一样的射击精度」:

图|YouTube @LinusTechTips

另一方面,手柄上两块硕大的触控板也为那些 Steam 上那些原生不支持手柄的游戏有了一个解决方案——比如一些比较老的 RTS 或者模拟经营类游戏。

不过目前版本的触控板在软件体验方面还有一些短板,Digital Foundry 在体验时指出:

手柄在 Steam 程序内的表现近乎完美,但在 Windows 系统层级会被识别为键鼠,如果不通过 Steam 运行,很难发挥其背键和触控板的全部潜力。

足够好用的手柄

除了 V 社一以贯之的优秀鼠标模拟体验之外,Steam 手柄在作为一个手柄的本职工作上也没有出现偏科。

虽然 Linus 和 Digital Foundry 都提到 Steam 手柄的摇杆为了给触控板让位置而有些「间距过近」,但对于这套精度极高的 TMR 摇杆本身的体验都是非常正面的。

图|YouTube @LinusTechTips

相比霍尔摇杆,TMR 摇杆的主要优势在于拥有极高的精度、抗干扰能力和温度稳定性,同时功耗也很优秀。

要说 TMR 摇杆的缺点,就是成本高——估计它俩是 Steam 手柄近 700 元售价的主要贡献者之一。

至于按键手感方面,Steam 手柄的 ABXY 和十字键均采用了静音薄膜,Linus 觉得有些偏软、不够清脆,Digital Foundry 则评价手感「厚实」(Clacky)。

与此同时,Steam 手柄背后的 4 颗背键则获得了一致好评。

Steam 手柄的背键键程长且舒适,稍微熟悉就能迅速上手,只不过触发力道有些偏轻,用力抓手柄的时候偶尔会误触:

图|YouTube @LinusTechTips

然而在最常用的扳机键上,V 社却做出了一个不太厚道的决定:

新款 Steam 手柄移除了初代上面的「两段式按压」,并且不支持震动反馈和自适应阻尼。

这就导致它在一些手柄适配好的游戏中,触感体验反而不如传统手柄,比如《地平线:西之绝境》中拉弓射箭的触感相比 DualSense 少了很多:

图|YouTube @LinusTechTips

另一个很值得说道的还有 Steam 手柄专有的磁吸充电器,它同时兼任无线接收器,在连接稳定性、延迟和方便程度上都得到了高度的赞扬:

图|YouTube @LinusTechTips

它是 PC 的延伸

整体来说,新版 Steam 手柄当之无愧地继承了 Steam Deck 与 SteamOS 的优秀口碑,完全有潜力成为 2026 年手柄市场中的一匹黑马。

但我们也要理解一件事:

Valve 在设计 Steam 手柄时,它们的出发点并不是直接与 Xbox 或者 PS 手柄抗衡,而是在为 Steam 游戏生态提供一个「键鼠之外」的延伸。

这种设计思路很好的解释了为什么 Steam 手柄在鼠标模拟、配置兼容等等方面,处处透露着 PC 硬件的思维,反而在扳机反馈这种传统手柄很看重的领域着墨不多——

Steam 手柄真正的意义不是让你扔掉 Xbox Elite 和 DualSense,而是扔掉客厅里的那套旧键鼠:

图|GamersRadar

这也很符合 Valve 立足 Steam 游戏生态的起始逻辑:

把本来被电脑机箱、键盘、鼠标和显示器限制住的 PC 游戏带到更多地方,就从掌机和客厅开始。

毕竟现在游戏主机的封闭生态越来越不好过,单纯打着「平台独占」和「硬件便宜」的招牌已经吸引不到太多新消费者了。

而 Valve 带着做 PC 的思路加入这个市场,希望能带来一些新的活力。

然而,最最重要的是,在面对新款的 Steam 手柄的时候,无论硬件如何,我们都不免问出一个和 11 年前一模一样的问题:

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

我对《缺氧》的游戏理解

最近一个月,我一直在玩《缺氧》(Oxygen Not Included) 。前几年玩过 100 多小时,算是比较熟悉了。但这个月又高强度的玩了 300 多小时,目前总游戏时长为 485 小时,感觉对这款游戏有了一些新的理解。

最初喜欢上这个游戏,是想找一个类似《异星工厂》的以自动化为核心玩法的基地建设类游戏。Factorio 是我最喜欢的游戏之一,游戏总时长达 2905 小时,是放置类游戏之外我花的时间最多的游戏。我很想看看类似游戏还能向什么不同方向发展。这两个游戏的目标都非常类似:在无人星球上殖民,建设一个基地发射火箭逃出升天。它们的拓展玩法有相似之处:发射第一枚火箭只是游戏的开始,需要继续探索星空和不同的星球,面对更复杂的挑战。所以,我一开始是从 Factorio 的角度去看待 ONI ,随着对游戏的理解,才发现它们其实有不同的内核。

ONI 初看的确像是 Factorio 和 Rimworld 的结合体(btw, Rimworld 我也有 123 小时的游戏时长,对它也有初步了解)。和 Factorio 的传送带特色不同,ONI 是基于类似 Rimworld 的工人驱动基地运作的。但 ONI 里的工人没有 Rimworld 中复杂的社会关系和社会情感联系,更像是一群无情工作的机器人。所以我认为它们像是 Factorio 里的无人机加上了细致编排任务的能力。

但玩了这么长时间后,我认为 ONI 和 Factorio 有着巨大的区别。

Factorio 的运作方式是简单清晰明确的,玩家可以在明确规则下不断扩大生产规模,而不同规模下的自动化需要解决不同的问题。所以,Factorio 玩家常说 The Factory Must Grow 。所以,Factorio 鼓励蓝图的使用、Mod 和游戏本体之间相互促进、不断完善更丰富的自动化手段。游戏除了标志性的机械爪传送带外,还有流体、电力和热量系统,它们都以相当简单的规则运作。其中略复杂的流体系统,在 2.0 也被简化为超级水箱,把“流动”去掉了。

ONI 的底层逻辑或许也很简洁。但它模型并非基于确定性规则的物流。相对比 Factorio ,玩家首先理解的是物品怎么在传送带上移动、如何被机械爪抓取;液体如何被传递,这些都和物流有关;但 ONI 首先传达给玩家的是气体的扩散和液体的流动,它们都是在环境中自动进行的:不需要玩家铺设轨道,玩家也难以精确控制它们。稍微深入游戏后,玩家还会发现,贯穿游戏的难题是热量。热同样以某种规则在环境中以单元格为单位交换,但热却无法作为一个实体直接操控。玩家需要去控制某个区域的温度,但却没有直接的手段。游戏后期最大的挑战是制备液氢制造远程火箭,这需要极低的温度;还需要驯服金属火山和岩浆,这又需要处理上千度的高温。

在缺氧中,资源在初期丰富但却有限。从游戏中期开始,玩家就会发现资源越来越紧缺,玩家的绝大部分手段都是在做资源转换:将 A 转换为 B 并可能伴随着质量损失。而绝大部分原始质量就是地图板块上的那些砖块,并不会凭空变多;相比而言 Factorio 的地图趋于无限,只要你肯向远方发展,永远有采不完的矿,解决好物流即可。同时,随着 ONI 中的生产活动,花掉的能量全部转换为热量。大多数游戏手段都是把热从 A 传递给 B ,而让热净减少的手段却极其有限,且藏得很深。

不看攻略的话,从游戏内对各种设施的字面解释很难直接找到减少热的方案。这也是新手通常都会在中期把基地变成 40 度以上的蒸笼而束手无策。初见游戏时,看到游戏界面中的文字大篇幅的罗列每种材料的比容、热传导率、热特性、固态液态气态的转换温度等会觉得离自己很远,但熟悉游戏后会发现,这些才是核心要素。

我最初玩 ONI 完全不得章法,基地盖得奇形怪状。这倒是和最早玩 Factorio 很像。但和 Factorio 不同,我并不完全靠自己摸索理清条理。看了几篇 ONI 的攻略后,我照着攻略指示修建基地,知道每个阶段要解决什么问题,大致怎么做。和 Factorio 明确的科技树驱动不同,ONI 的科技树其实爬得很快。玩家很少被卡在科技上,甚至在游戏中期就能解锁大部分科技,整个游戏过程也不会被科技进度卡住。真正困难的是,大部分科技解锁的物件,从字面理解上都很难想到它能做什么,有什么副作用。我感觉从这点上,ONI 的门槛比 Factorio 要高,很需要攻略引导。

前几年,我最初的 100 小时游戏就是按某篇攻略引导玩进去的,并深得其乐。但最近几百小时,我发现自己琢磨能玩出非常不一样的感受。游戏流程也和之前攻略引导的体验截然不同。最显著的差异就是:我最新的一盘直到在第三星球开荒,一共只养了四个小人。其中三个是开局选的,第四个是在第二星球上系统送的。也就是整个游戏过程,我都没有在传送门要一个新的小人。

绝大部分 ONI 的攻略都不会介绍这样的玩法。玩家或许把不加人手的玩法视为高手的挑战,但我是在理解了这个游戏的内核后,发现这是推进游戏进程的最佳手段之一,而且游戏过程会非常轻松。我来解释一下这种游戏思路的内在逻辑:

前面说到,游戏的大部分资源都是地图上的方块。只有喷泉和流星雨是从外部补充的净增加质量,对眼冒金星 DLC 而言,母星去掉了流星雨就只剩喷泉。游戏过程的生产活动,本质上都是资源转换。例如,你可以把小人看成将氧气加食物转换为二氧化碳和废水的转换器;食物则通常是由动物或植物将泥土转换而来,烹饪过程可能有净水参与。把两者联合起来看,小人把氧气 + 泥土 + 水转换为了二氧化碳和废水。

最大的例外是科研,基础科技是对水和泥土的净消耗。也就是水和泥土消失了,点亮了科技树。

同时,所有的生产活动都需要消耗能量。这是一个能量到热量的转换过程,最终反映为地图温度的升高。这个游戏本质上是在治理混乱,即减少地图的熵。把地图上的不同砖块转换为有序的基地,有效的维持玩家主动导向的转换过程,同时系统以某种内在规则让物质在地图上自然流动:这包括了重力作用下的液体流动、开采的砖块碎片自然掉落、气体分层等。由于一切转换器(工人、动植物、机器)都有适用环境,生物需要对应的气(液)体环境、光照、温度;机器相比生物对环境的要求没那么苛刻,但也是存在的。所以玩家建设基地就是分两个阶段处理问题:一开始的建设阶段把对应的材料搬运到位、随后的维护阶段维持环境的稳定性。

无论玩家养多大的工人规模,科研的总净开销是一样的。游戏的前半段,需要的核心转换是 1200 kg 的钢,用于制造第一台制冷机。因为制冷机+蒸汽机组合是游戏最稳定的将热净减少的方式。铁转换为钢的过程受限于石灰的产能,通常在初期是蛋壳。需求和产能也是恒定的,也和工人规模无关。

而且,游戏里大量的资源转换环境其实起的作用更大,并不需要花特别多的人力,而玩家只要用小人下达指令后,更多的等下去静待花开。

更少的工人意味着在产出第一台制冷机前,更少的生产活动,更少的做资源转换。维持工人的核心在于平衡氧气到二氧化碳的转换过程。这里分两个问题:制备氧气和处理二氧化碳。

制备氧气在前期主要是两个途径:用藻类转换或分解水。

藻类是相对有限的,但养活三个工人和八个工人其实区别不大(通常不会消耗完),细微的差别在于挖空地图导致的空间扩大导致的气体扩散。虽然总量不变,但熵增加了。新手很容易到处开挖,但我的经验是越早把基地封起来有选择的逐步扩展才会减少要处理的问题。

电解水制氧看起来干净的多:不需要挖藻类,而初期基地周围的环境水本身就需要治理(否则无法按规则规划基地)。但游戏隐藏了一个副作用是新手很难注意到:电解水制氧会产生额外的热。前面说到,游戏本质上的核心挑战就是热治理。所以我认为把这个问题推迟(到科技树基本爬完)有极大好处。所以,保持一个极小团队,有利于推迟电解水制氧。事实上我最近一盘游戏直到游戏后期需要氢气之前都没有电解水。

另一个问题是处理二氧化碳。在发射近程二氧化碳火箭之前,二氧化碳几乎没用。有两种手段处理它:用碳素脱离器处理掉,或存起来。因为中后期一定会适用二氧化碳火箭,我认为存起来比较好。但在开发太空前,很难找到低温区液化或固态化二氧化碳,保存气态二氧化碳非常占空间。所以,二氧化碳转换得越少越好。早期在开发太空前一定会用煤炭发电过渡,这是部分二氧化碳源头,另一部分就是工人的日常呼吸了。更少的工人意味着呼出越少的二氧化碳。电力消耗也会因为工人数量减少而略微减少,但少的不多。人数增加而增加的电耗主要是在食物制备。科研、生产石灰、精炼金属这些基本需求倒是和工人规模相关性较少。

工人偏少最明显的劣势是干活的人少了,玩家可能会觉得游戏节奏无意义的变慢,实则不然。在 Factorio 里,新手通常不太愿意扩大生产规模,因为那意味着脱离已经经营好的舒适区。但 ONI 不同,规模化生产在游戏大部分时段几乎难以带来好处。玩家在中前期要解决的问题并不太多,一步步总能做完,它们并不能靠扩大生产规模提升效率。相反,人越少要做的维持生存方面的工作越少,专心做推进科研和基地发展的步骤就可以了。用三人团队发展,从游戏内时间看,迈入游戏中期的总周期数比一个八人团队明显要长,但实际游戏时间却不会增加太多。这是因为,游戏内小人干得慢了,但可以用最高速度推进游戏时间;而大规模团队通常会用最慢速度玩游戏,甚至还要时常暂停。本质上来说,维持最小团队,推进游戏需要(点鼠标)的操作数量变少了。小团队也会大量减少中后期工人闲置的时间。

另一个优势在于:工人干活是会加经验升级的。升级带来了能力的成长,提高了工作效率。因为总的工作量差不多,所以越小的团队,经验越集中,就能更快的得到几个高素质的全能工人。劣势或许是人数太少发展需要的技能不够,在多人团队中,这往往是不同发展方向的人承担的。无论开局怎么刷,三个人都无法全部覆盖需要的专长。但我的经验是:在中期洗点,只要规划好每个阶段需要做什么,完全够用。例如:只有在装修和做化石勘探任务时才需要大师艺术,做完就洗掉即可;同理,铺设传输轨道需要的高级技能,也可以在需要时再点出来,做完项目就洗掉即可。

最近玩 ONI 给我的感受是:玩游戏不能着急,需要规划好,一次做一个工程。这其实是一个慢节奏游戏,让小人生存并不难。下指令容易,但执行需要很长的游戏内时间。相比 Factorio 会发现,修建一个设施需要极长的时间:改造场地环境、远距离搬运材料、建造;改建(拆除)甚至比建新的还久。但 ONI 一盘游戏必须要做的工程并不算太多,几乎都是一次性的。所以,这个游戏不像 Factorio 那样依赖蓝图,反而因地制宜处理问题更多一些。尤其是,环境的自然变化:液体流动、气体扩散都需要很长的时间,把游戏节奏慢下来,利用好环境的自然变化反而要做的总工作量会减少。欲速不达是新手常犯的错误。例如,不把基地封好就出门到处乱挖,导致后期治理要花更多时间。尤其是病毒进入基地、不可呼吸气体混入氧气环境都是一瞬间,但再想处理干净却是及其费事的。

这些小问题(环境的恶化)并非致命,但会潜在削弱长期的工作效率,或增加远期治理的工程量。新手和老手基地往往在视觉上就有极大不同:整齐规划的干干净净。装修房间,清理杂物是看起来短期收益最小的工作,装饰度提高的长期收益很容易被忽略,尤其是人手不足的时候不想先做。但实际上,这种迟早要完成的工程,只要不影响生存,反而应该早点完成。


ONI 对我来说,最重要的游戏体验是不断发现小问题并提出解决方法。这得益于游戏内的物理规则制造的环境让同样的问题有不同的解决方案。每种方案都很难做到完美,总有一些副作用,而游戏者对游戏理解越多,就越能清楚如何承担这些副作用。

比方说,制备氧气是游戏的基础,游戏名就叫做 Oxygen Not Included 。但所有的制氧方案都是把氧气排放到环境中的。好在小人生存需要的氧气也是从环境中摄取。但一旦需要提取氧气使用:比如冲入氧气面罩或太空服,就需要把氧气放进管道,从环境中分离氧气就麻烦的多。直接的方法是用抽气机加气体分离器。看起来很彻底,但需要的能耗却不应忽视。不想 Factorio 那样,缺电就想办法扩展电网,ONI 里要考虑烧煤导致的二氧化碳治理问题,能量消耗带来的热量问题,这些都是短期看不到的问题,但长期游戏必将受到影响。

藻类制氧可以制造一个纯氧房间,这样就能节省一个分离器。但人工添加藻类时可能带入的二氧化碳就可能是一个干扰因素。运输轨道和无人机运输都是解决方法。环境气体元素信号器不耗电,可以用信号控制减少制氧室混入的其它气体,也能解决一部分问题,但不彻底。不过,ONI 中其实不需要彻底解决问题。因为和 Factorio 不同,在 Factorio 的传送带上混入杂质会堵塞整条流水线,必须手工清理;而 ONI 偶尔在氧气管道中混入一点杂质气体,只会引起设备的损坏,小人会自动修理。只需要权衡这个维修开销是否能值回票价:剩下的气体分离器的开销。为了让优化掉气体分离器更有价值,ONI 里大部分机器其实是不太耗电的,或是有极短的工作时间,大部分闲置,所以整个机器需要的总电量在优化得当时并不高。而气体分离器这种只要通气就得需要长期工作的机器反而显得功率占比很大。对比 Factorio ,传送带筛选器是不耗电的,除了太空上的空间限制,都是鼓励你使用。这个差异导向了不同的游戏体验。

同理,电解水制氧,你可以在管道中分离氢气和氧气(以及环境中可能存在的杂气),也可以设计好房间利用气体的自然环境分层。但依赖环境一则需要用时间来换,二是气体扩散过程的随机因素导致不能 100% 确定。

凡涉及气体隔离和液体分离都有类似问题。最常见的是制作真空室,它是做氯气消毒室的前置,也是做辐射管道的基础,还可以用于隔热。从多道气闸的信号控制,再或不同水门(用液体隔开不同的空间,同时让人可以穿行)的搭建方法,都伴随着很多隐晦的副作用。例如看似完美解决问题(隔离真空室)的水门可能带来一瞬间让小人湿身的负面 buf ,或是可能让无人机浸水,还可能因为温度变化液体发生相变。ONI 中并没有直接提供一个可以完全隔离两个空间的气闸门,而是设计成开门会有一小段时间漏气或漏水,这留下了很多的操作空间。

ps. 如果你真的想不耗电过滤气体,在充分理解 ONI 的流体系统后,可以用气阀和管桥巧妙的搭建出一个机构解决这个问题。有兴趣可以在 youtube 上找 3 Ways To Filter Gas! Oxygen Not Included Tutorial / Guide 这个视频来看。


最后,介绍一下我的游戏开荒流程,可以作为针对网络上其它常见攻略流程的一份补充。开荒指基本开发完母星和第二星,用短程火箭开发第三星,并研究出中程火箭,可以去更远的星球。

2.0 眼冒金星的标准模式中,第二星和母星有传送器互联,可以双向传输人和物资,所以可认为是一体的。如果玩经典模式,即更大的母星则需要做一些调整。

如前文所述,我的游戏流程最大的不同是只用系统给的工人,不招募任何新人。所以初期一直用三个人,在第二星上获取第四个。如果有“神秘隐士”这个故事特质,可以在最后招募一个高属性小人作为补充。但最好不要选“梦境合成器”故事特质,因为需要通过延长睡眠时间(甚至专门的做梦团队)获得全员属性提升很不划算。毕竟全员也没几个人。

可以把游戏开荒过程看成是若干个小的项目,因为人手少,所以大致串行完成这些项目即可。

第一个项目是挖出基本空间,并开发初级科技。

开发初级科技只需要泥巴(一级)和水(二级),这是一切的基础,所以必须最先完成。挖出最小空间额外建两房间,其中一间卧室,一间临时厕所。初始传送门自带光源,所以可以就地改造成科研室。房间全部用 16 * 4 的规整空间,可规划为以中间通道为轴堆成,每层左右两间,纵向发展。我倾向于左侧生活区,即科研室、卧室、卫生间、食堂、温室,后期保持 25 度以下环境温度;右侧偏生产,放置更多热源。左右两侧之间留两格的通道即可,一列纵梯,预留一列滑杆。

由于高压电缆和变压器有极高的负面装饰,所以我倾向于放在工作区的更右侧并用墙隔开,然后每层靠墙设一个变压器,然后是检修用的第二梯子加纵向高压电缆。高压电缆的右侧可以留下未来的无人值守区,用于发电、蒸气室等。进入无人区需要留一个房间放氧气面罩站。

综上,基地横向每层三个 16 * 4 的房间,两个纵向通道。

在这个阶段,厕所是临时的,可以扔在右侧工业区,未来会拆掉。而生活区的卧室是永久的,所以可以建在科研室的正上方(初期氧气充沛)。至于水源,早期基地附近肯定有,可能面临的问题是占据了规划中的房间位置。所以需要留出足够位置,不用破环规划。

在第一个阶段,如果克制的开挖空间,是不需要制氧的。因为不招募新人,所以地图上的氧石挥发氧气就足够用了。食物也不需要补充,开局送的营养棒和挖土翻出来的淤泥根够吃,所以不需要修建食物压缩机。唯一要建的是人力发电机和科研台(唯一耗电设备)。

第二个项目是建造卫生间。

我之前看的攻略大多是快速建立煤炭发电来取代人力发电机以节省人力。但我认为人少的时候初期生存压力也少(因为系统开局送的生存资源是一样多的),人力其实完全够用。三个人大致的分工是一个科研,一个发电,一个建设。相比烧煤发电,通旱厕反而是更浪费人力的工作。如果顺利的话,完全可以在两个旱厕都堵住前,让自动化卫生间投入使用。

卫生间的水是可以自循环的。即冲厕所和吸收用的水远少于小人排除的废水,配合净水器反而有废水的净产出。需要考虑的是如何处理多余的废水不要堵塞管道的问题。一般的解法是让多余的废水送去液培砖种芦苇。之后做太空服正好需要芦苇。

至于地图附近有没有芦苇可以拔来种要看随机刷的运气,通常是有的只是远近问题。采芦苇时应该采取最小空间破坏原则,挖到就把路重新堵上,避免带入过多病毒,以及不必要的氧气扩散。

卫生间和净水房分开,我试过两个方案,其一是和卫生间上下两层,净水房后面兼做农场;后来发现更好的是左右两间,兼做仓库。

注意这里卫生间产生的废水净化后不要引入净水储备,因为其中有食物中毒病毒。让它们自循环和种芦苇即可,和基地其它用水完全隔离。如果节奏安排得当,还可以点出装桶和倒桶科技,同样放在净水房中。这时就可以拆掉一开始的手压水泵,并把拆掉旱厕扔出来的废水投入卫生间的水循环中。这可以省掉基地外额外挖一个坑倒废水的工作。

废水最好能尽快处理,尤其是在它挥发太多的污染氧之前完成。基地中混入一些污染氧虽不致命,但影响工作效率。

这个阶段,工作量其实是不均等的。科研的活最多,但当然不能让小人闲下来。但原则是整理基地,即使是收拾杂物也比向外开挖更重要。

第三个项目是修建米虱壁虎农场和哈奇煤炭生产间。

米虱是重要的食物来源,人少的话可以吃很久,而且腌制米虱由于保质期很长,还可以在其后用于短途太空旅行。不少攻略建议这时开始种蘑菇,我认为在人少够吃的情况下完全不必。倒不是种蘑菇麻烦,是因为处理菌泥带来的病毒需要的步骤较多(需要收集氯气消毒)。如果不处理病毒的话,就涉及后面会面临的病毒治理问题。

普通壁虎很好捉,但养出产塑料的变种比较花时间,所以要尽早养。如果运气好在附近挖出小动物变异器这个故事特质就更省事一些,不然多生几次蛋也能出来。塑料不是很着急,开荒需求也不多,完全可以等养出滑鳞壁虎产出。不需要特地去建石油产线做塑料。

哈奇可以把砂岩转化为煤炭,开荒期电力省点用的话,就不需要出去挖煤了。而且哈奇产蛋量较大,蛋壳是开荒要的那 1200kg 钢的原料,石灰的稳定来源;而且少量的生蛋可以作为食物补充。对于稍微有点规模的基地,比如传统的 8 人基地,这点生蛋肯定不够吃,但超小的 3 人基地,则不容忽视。这也是不需要种蘑菇,后期也不需要种冰霜小麦的原因之一。

如果运气不错在地图中间找到同伴芽的话,可以挖回来种上传播花香。但这属于锦上添花。

这个阶段如果氧气不足,可以随便加两个藻类制氧机。

第四个项目是装修基地,扩建出氧气室和发电房。

随着基地的扩大,为了提高物流效率,早点点出滑杆科技是有价值的。因为煤炭发电出的比较晚,所以二氧化碳问题不会太严重。空出一个房间专门制氧是有必要的。通常放在基地上方右侧的工业区,因为一般而言制氧过程都伴随着热量产出(单纯藻类制氧不严重)。为了减少后面分离出纯氧的难度,早点在上方留出纯氧室比较好。

这时不推荐电解水制氧,原因前文已经阐述了。但养壁虎需要一点氢气,推荐在地图上抽过来,否则电解一点水也也是可以的。

发电房放在基地最右下,后面会和其它部分隔离开,所以要留出一个房间用于内部的氧气检查站柜。

第五个项目是出门前的准备,包括密封基地,氯气室,氧气站、太空服等。

这个项目的目标室把基地和外部完全隔离开,出门带上氧气面罩,最好是太空服。氯气室用来消毒。但不需要一步到位,一开始只需要抽取附近环境中的氯气。扩建基地一定会遇到氯气区,这时需要先在入口先建好气泵,然后密封抽真空。这个过程漏一点氯气无所谓,反正随着时间会自然分层,到时候在基地下方和堆积的二氧化碳一起分离即可。抽出的氯气装箱后,通到基地的出口洗矿。这是很多新手会忽略的开发步骤,因为病毒的危害并不会立刻显现,但是处理病毒的过程会比较漫长。

如果病毒进入基地,处理起来也不算麻烦。如果前面卫生间水循环搭建正确,应该不会有食物中毒的问题,主要会遇到的是粘液肺,多见于挖开菌泥区。如果种蘑菇的话,不洗掉菌泥上的粘液肺,就很容易在基地蔓延开。粘液肺在纯氧环境会慢慢消失,所以除了隔绝病毒外,重要的是净化掉基地内的污染氧。同时,吸入一口污染氧还会给小人一个短期的负面 buf 。所以在基地口的氧气面罩检查站外,需要和出门气闸间留一点空间,避免开着门换衣服。

前面几个项目按部就班的话,因为只有三个小人,所以生存完全不会有压力。操作强度也不大,或许游戏内的周期过了不少,但大部分时间都是在加速运行的,真实游戏时间不需要太长。

接下来要做的事情主要有两个,都是需要出门完成的:为开发第二星做周全的准备以及开发星球表面发展太阳能和火箭基地。

开发二星一般需要挖通三个设施,分别是小人传送站和发送以及接收物资的站点。我觉得把物流提前打通,也就是把管道都修好再去二星会让后面的工作简单很多。这样一到二星,就立刻可以利用母星的资源。

眼冒金星 DLC 的开局母星非常小,所以都不会在很远的位置,应该马上就能看见。挖路要尽量少挖,用最短距离挖过去,然后把管线拉通即可。穿好太空服再做这个工程可以提升不少效率。顺便还可以把附近的故事特质完成了,尤其是小动物变异器对获取滑鳞壁虎很方便。

但是,铺设固体传输管道需要大量金属,所以可能需要专门开采铜矿。采矿机就非常有用了,可以节省大量人力。但如果从机器拉电缆可能比较费事,比较简单的方法是做电池,使用两个袖珍放电器就可以带动一个采矿机。电池还可以用于物流无人机,早点做两三个无人机,完全就不会有物流负担,基地的杂物也会自动被整理的干干净净。

另一方面,直接向上挖通地表即可,也穿上太空服。到了地表后第一件事就是铺太阳能板。早点关掉煤炭发电可以省去好多麻烦。路上如果遇到低温区,可以把玻璃和金属精炼等热量大户先临时塞进去,这样就不会破坏基地内部的温度。后面建好蒸汽房还可以搬回来。

一旦攒出 1200kg 的钢,就可以开始搭建蒸汽房了。蒸汽机加冷凝机是最通用的热量消除机构。因为蒸汽机是唯一一个确定且直接的设备,可以热量转换为能量。它吸入 125 度以上的蒸汽,转换为 95 度的水,同时发电。这里发电是次要的,最重要的用途是这个过程热量消失了。但为了获得 125 度的蒸汽,除了在后期可以利用环境外,稳定的主动手段就是使用冷凝机。它的工作原理是输入高温液体,输出低温液体(可以用于基地其它的降温用途),其中的温差变成热量有机器本身散发到环境种。所以,冷凝机本身不消除热量,它只搬运热量。虽然系统本身热量减少了,但冷凝机的工作过程会产生大量的环境热,它正好用于把水烧为超过 125 度的蒸汽。但这样,冷凝机本身必然处于高温中,所以必须用耐高温的钢来制作。这就是开荒需要 1200kg 钢的原因——制造第一台用于烧开水的钢制冷凝机。

怎么搭蒸气房网上有很多介绍,这里就不细讲了。但我想说的是,可以参考攻略,但完全不需要抄攻略中的图纸。一旦明白原理,自然会有很多想法,肯定会做出不同的蒸汽房设计。ONI 和 Factorio 不同,它更难存在最优解,一切都和游戏过程相关。

如果是三人基地,其实搭蒸汽房降温的需求并不强烈。比如我玩的最新一盘,搭好蒸汽房后,基地平均温度才不到 20 度,要解决的是略微增温而不是降温。但温度调节迟早是需要的,工业化温控这是必须完成的基地设施。当然这不是唯一的路径,有兴趣的话还可以试试用冰霜萝卜控温,或是将高温二氧化碳到地表固体化带走基地的热量。

一般来说,开发第二星的主要目标是建立起石油产线。表面上看起来,石油是工业化生产塑料的基础。但其实游戏的开荒期塑料需求并不大:装修完基地,改造地板和梯子,建立通向地表的载人管道,这些用壁虎产出就足够了,完全不需要通过石油生产。

石油除了中后期做石油引擎的中程火箭外,最重要的用途是用于金属精炼的冷却剂。所以我们只需要做一点点出来就够用。

一开始只能用水做金属精炼冷却剂。如果背靠冷源,比如附近就有低温喷泉,那么这种天然冷却源就可以稳定的工作很久。但如果自己在基地内部做冷却循环,就会发现经常需要修机器。因为金属精炼,尤其是炼钢,会放出大量的热,让冷却液迅速升温。而水超过 100 度就会气化,太低温度会结冰,这些相变都会破坏管道。放置温度巨变要么需要一个相当大的热容器,比如上面提到的大水池,尤其是天然冷源;要么就需要很复杂的自动化控制机构。虽然把玩自动化机构也值得玩很久,但更简单的方法是换成石油做冷凝剂。油的比热容比水小,炼钢时温差更大。但这反而是优势。因为超过 125 度的油就可以用来烧开水,用蒸汽机带走热量,同时还能回收部分电能。

所以,游戏中蒸汽机加炼钢也是一套基础的机构。懂得原理的话,也可以玩出很多很多不同的设计。

第三个星球就需要找出火箭去了。它通常很近,所以用二氧化碳引擎最简单。这时,游戏前期存的那些二氧化碳就用得上了,而且二星上的石油工业副产品也是二氧化碳,可以直接传送回母星,基本是不缺燃料的。

二氧化碳引擎速度快,尾焰温度低,对环境破坏最小。唯一的缺点是不能造大火箭。但小小的单人空间把弄起来也格外有趣。火箭部分我完全没看过攻略,有了前面足够的游戏经验,我感觉自己摸索更为有趣。火箭上主要需解决的问题是怎么让小人在里面舒服的活上几天。燃料和航程在这个阶段都不需要考虑。

而小人的需求无非是食物、卫生和氧气,以及避免高压力。

这个时候,因为人少的优势,每个人都会成长的很好,所以洗掉不必要的技能点,只点出驾驶的话,压力完全不会是问题。短途并不需要储备太多的食物,如果是两三天往返的话,随便扔点食物在火箭内就不会挨饿。

氧气用藻类制氧机就能解决,只要在出发前排空舱内的二氧化碳即可。如果肯盯着高气压的负面 buf 的话,把高压氧气压入舱内也能用很久,这样也可以不必设制氧机。所以这里也有很多不同的解决方案。舱内空间非常的小,所以需要做很多空间上的选择。

最后是舱内上厕所的问题。无疑需要用壁挂强排厕所最省空间,但充厕所的水怎么办?我第一反应是装个水箱,但一个水箱(3x2)就占掉了一半的有效空间。随之发现,其实排灌器就是用来这里的。1x2 的空间可以存 200kg 的水,只是用于冲厕所搓搓有余。

等开发完第三星球,以及搜罗完太空的数据卡,基本上科技树就爬完了。这时可以拆掉基地的科研设施,开始转石油火箭去更远的星球拿石墨做富勒烯,制造超级制冷剂。利用它降温才能制备液态氢,然后就是做液态氢引擎达到最大航程通关游戏了。

我暂时还没有玩到最后,所以这里就无法介绍后期的游戏体验。

Star Trek : Captain's Chair 初体验

今年过年,我沉迷于 Star Trek : Captain's Chair 这款 2025 年的桌游。暂时还没有中文版,如果直译的话,名为《星际迷航:船长之椅》。这是一款以卡牌构筑为核心玩法的桌游,在游戏过程中,不断完善自己的牌堆,构筑一个高效的得分引擎。如果能比对手获得更多的 VP 就可以获得游戏胜利,但也要避免突然死亡。这是一款新游戏,但作者 Nigel Buckle 和 David Turczi 之前已经用类似的系统出过 Imperium (帝国)三部曲。其中《帝国:经典版》和《帝国:传奇版》有中文版,在淘宝上就可以买到。btw, 前段时间我玩过的 VoidFall 也是他们的作品。

这个游戏的规则还是挺复杂的,在 BGG 上的 weight 评级达到了 4.06 。注:游戏的重度(weight)是由玩家评分综合而来,最高为 5 。它指的是规则的繁杂程度,而并非游戏的策略深度(通常有相关性)。例如围棋虽然策略深度几乎达到了桌游的天花板,但它的 weight 就不到 4 。而 bgg 上 weight 超过 4 的游戏并不多见,大部分超过 3 的桌游,一般就被归为重度游戏了。我大概花了 10 多个小时试玩,看了几个小时的教学视频,才感觉学会了游戏的基本规则。不过一旦理解了游戏的设计逻辑,玩起来还颇为流畅,规则书以及规则助记版都非常符合直觉,简单好认。重度游戏大多不太讨人喜欢,但设计良好的重度游戏也能带来更多乐趣。

我认为 ST:CC 是我这些年玩过的所有卡牌构筑类桌游中机制、策略和局势变化最丰富的。它提供了及其丰富的机制让玩家控制牌组的构成,这也是“构筑”这个机制的核心玩点。和最早的《Dominion 领土》作比较:这类游戏的基本玩法就是从市场购买新卡,构建一个得分引擎。分往往也体现在牌组中,但会稀释行动牌的价值(通常分卡在游戏过程中没有收益),让玩家在构筑过程中做出权衡。Dominion 每局游戏的后期通常会面对厚厚的牌堆,行动会变得越来越不可预测。后来的同类游戏逐步加入了更丰富的机制来帮助卡组瘦身,提供给玩家更多确定性,更好的控制自己的行动。

ST:CC 以及它的前身 Imperium 提供了非常丰富的卡组瘦身机制:

  1. 可以把卡堆里的牌 LOG 起来:和早期卡牌构筑游戏不同,得分卡并不是专门的卡,而是每张卡本身就带有 VP 。这更像银河竞逐这样的引擎构建游戏。收集的卡越多得分越高。LOG 可以把当局游戏不再用的卡从当前卡堆里移除,但得分依旧保留。

  2. 可以把卡片 deploy 到桌面:放在桌面的卡可以提供持久的被动能力,也可以有限的提供主动能力或响应能力。同时,活动卡组也得到的瘦身。根据卡片属性不同,提供有差异的回收规则。船员卡可以常驻一张,新的船员卡晋升后 dismiss 旧的;飞船卡则在占领星球后自动 dismiss ;事件卡则每张有不同的回收前置行动(不回收会在游戏结束结算时计为负分)。

  3. 卡片可以 beam 到飞船或星球上:这可以对卡组作更灵活的临时瘦身。几乎所有的飞船都有主动能力可以 beam 手牌,但反向回到手牌的 recall 操作却比较稀少。不过,beam 在飞船上的卡也可以随飞船 dismiss 而一同回到弃牌堆。

永久(不可逆)和临时(可收回)的卡牌瘦身操作,可以让玩家在游戏过程中动态的调整卡组,让游戏的确定性更高,而不会在抽牌堆太大时,过于依赖抽卡的手气。就我这几天玩的数盘游戏体验,通常我的活动牌组(抽牌堆加上弃牌堆和手牌)在整局游戏里也很少超过 20 张。

ST:CC 在游戏过程中的卡组升级也有新意。

首先,和大多数卡牌构筑游戏一样,初始卡组是 10 张左右,每轮抽 5 张。这样可以保证前两轮可以作一个轮回,让随机性限制在 10 张卡的不同组合上。但和之前的很多游戏不同,它的 10 张卡是完全不同的,每张都特别设计过。甚至游戏带了 6 套风格迥异的初始牌组。而传统上的设计更偏好在初始卡组中放上雷同的初始能力卡,加上很少量的特殊卡。如果没玩过桌游的话,可以对比杀戮尖塔这样受桌游启发的电子游戏:一开始的初始卡组中只有一张特殊能力卡加上普通的打击和防御。

而和一般的卡牌构筑游戏的升级流程不同,它会为每个初始牌组设计 5 张左右的固定补充卡堆和 8 张左右的高级补充卡,以固定节奏补充进来:每次抽牌堆抽空都会自动触发这个补充操作,加入一张额外的补充卡。基本的补充卡的随机性在于每局游戏的进入次序是打乱的,而高级卡则需要用不同资源购买,但可以让玩家指定(没有抽卡的随机环节)。这样熟练牌组的玩家可以预先学习好每个角色牌组的策略,再实际玩的时候又不至于形成太固定的套路。

因为补充卡是通过卡组循环进入抽卡堆的,添加新的补充卡可以带来更多的组合,相当于卡组升级。所以调节抽卡堆的消耗速度就相当于控制玩家卡组升级速度。ST:CC 在主动控制牌堆轮转这一点上设计得比大多数卡牌构筑前辈出色。

1.抽牌能力。这是一个常规设计,不仅用来补充当前回合的行动选择,同时加快了抽牌堆的轮转。

  1. 弃掉抽牌堆顶的牌。这在《Dominion》中多以攻击效果出现,说明早期这种设计更多强调的是其负面影响:让玩家暂时失去潜在的行动能力。但由于卡组瘦身很容易,它也出现了有益的一面:加快牌堆轮转。

  2. 在回合结束时,玩家可以任意保留手牌。这给玩家了选择:确保下一回合能做的行动,但减缓了牌堆轮转速度。

  3. 从市场获取新牌后,可以自由选择放入弃牌堆还是放入抽牌堆。前者加快了牌堆轮转,后者提供了确定性:可用抽牌能力立刻获取,或确保下一轮可以抽到。

由于有大量从弃牌堆抽牌的能力。这极大的丰富了获取行动卡的途径。抽牌堆抽卡是随机的,弃牌堆抽卡是确定的(挑选),牌堆轮转加快固然是好事,但弃牌堆清空也是需要考虑的问题。

除了固定补充卡升级,游戏还提供公共市场和供双方争夺的中立地点卡。但很多传统的市场机制是用资源从市场买卡,而 SC:CC 并不通过积累资源购买市场卡,而是改为用特定行动卡片直接获取。市场被分为了四类:船员、货物、飞船、盟友,分别对应不同的行动卡去获取。根据选择的初始牌组不同,获取这些市场卡的行动卡使用方式也不一样。由于行动力有限,规划行动的分配获取市场卡就变成了卡片 combo 重要的一环。玩家很难积累获取市场卡的能力,抢夺地点卡更是这样:规则限制了每个回合最多只能获得一个中立地点。整局游戏中不会获得太多的额外卡片,且每张公共卡都是单独设计的,这让引入每张卡到自己的卡组都需要仔细规划。

ST:CC 的卡片被设计成一卡多用。卡片处于不同位置:从手牌打出或桌面上激活会有不同的能力。而即使是同一种方式使用它,一般也有多种能力供选择,只能选其一使用。虽然每种使用方式大多有前置条件或副作用,但本身的多种选择让每张卡片在不同场景下都有用。

因为卡片的位置非常丰富:除了传统的抽牌堆、弃牌堆、手牌外,还有桌面区、市场区、当前市场、市场库存、中立地、废牌堆、附着在其它卡片上、LOG 区、升级区、事件区等等。就我主要玩的 PICARD 牌阵来说,大量的行动就是将卡在这些这些区域之间调度。所以在玩的时候,有一点工人分配游戏的感觉。不仅提供了丰富的卡牌策略,还非常好的契合了星际迷航那种驾驶飞船探索宇宙的主题。


为了让游戏不限于千篇一律的构建得分引擎循环,游戏给每个牌组都设计了不同主题的任务。任务不同于很多引擎构建游戏的终局任务卡,那个在 ST:CC 里也有,被设计为 Encouter 卡片,通常可以提供大笔的 VP 。任务就是固定在每个初始卡组上的,像是堆每组不同风格的牌作一个游戏引导,引导在游戏过程中侧重某种玩法。例如,PICARD 的基本任务就是获得三张同盟卡,并把他们都 beam 到同一艘飞船上,且获得至少 4 点科技点和 4 点影响力,就可以完成。

这个设计不会让玩家(熟悉后)玩游戏时不会走一步看一步,每步寻找当下行动的利益最大化。玩家必须作一个长远规划:因为任务必不可少的需要分成很多步骤,同步相当多的行动在好几个回合才可能达成。以我玩的经验来看,基本任务一般在游戏中后期才可以达成,而以开始不作计划的话,常常忙到快结束时还差上一点点。

由于只靠固定牌组很难有效的完成任务,随机出现的公共牌加入卡组都能带来意想不到的高效组合,所以每局游戏的过程都会差异很大。我用 PICARD 玩了 3,4 局游戏,都选的 KOLOTH 这个 bot ,但每局游戏体验完全不同。更别说换掉对手会有完全不同的局面。游戏为每个舰长的 bot 定制了不同的自动化策略来模拟人类玩家选择不同舰长会出现的不一样的打牌倾向。

这是一个两人对战游戏,但也可以用设计好的自动化规则来模拟一个对手。但在 BGG 上,大多数玩家认为这个单人对抗 bot 的玩法更好玩。游戏的教学作的不错,提供了一个更存粹没有对手的单人模式,通常用于熟悉牌组。这个教学模式就是无干扰的刷分,刷够足够的分就胜利了。通过玩这个模式,可以体验不同舰长牌风格迥异的 combo 策略。通常建议把 6 个舰长都刷够分,这样在对战时既能知道自己应该怎么玩,还能熟知对手的策略。

正式的单人模式是对抗固定规则的对手。采用的是不对称规则:玩家和 bot 的行动法则是不一样的。我没有玩过对战模式,但据 BGG 论坛玩家的反馈,预设规则把和真人玩家的对抗时会产生的交互:争夺市场卡片、抢占中立地点等模拟的很好。一开始玩的时候,操作 bot 很容易出错,但玩过一盘之后就非常顺畅了,bot 每个回合一两分钟就能操作完,反之自己这边的行动每个回合会花很长时间。可想而之,和人对战应该会有极大的 downtime ,怪不得大多数人都选择了单人 solo 。

但我觉得,如果有个人类对手和自己一样玩过很多盘 solo 的话,再在一起对战应该也是非常有趣的。

官方还为单人模式设计了一个长线的五年计划规则。让玩家可以连着玩 5~10 盘游戏,在每盘游戏间加入了牌组升级:每次胜利都可以加入当局游戏终局时的某张市场卡进入初始牌组,或是 boost 一些初始能力。由于游戏设计了 6 组不同的牌,这相当于需要击败 5 个不同的对手(自动化 bot ),想来不会有太多重复感。我打算熟悉玩所有卡组后就尝试一下这个长线任务,应该会很有趣。


很想买一套实体版,但在淘宝上找不到代购,甚至目前美国那边也缺货等着重印。我这几天都是在桌面模拟器上玩的(有玩家制作的 mod )。我的感觉是,由于电子版缺少触感,细节更容易玩错。即使很熟悉后,游戏效率还是比不上实体。这点和版图游戏颇为不同,这个几乎全部用卡牌作道具,假若是实体牌的话,电子版只在洗牌时会便利一点,打牌及查看牌面要麻烦很多。而很多版图游戏,电子模拟器在 setup 以及游戏过程中的摆放都会更方便。

作为 solo 游戏,实体版最方便的地方在于易于反悔。只要没有信息揭示环节(例如抽牌后查看),大多数行动你都可以方便的在牌桌上 undo ,尝试各种不同的组合。电子模拟器上的 undo 操作一不小心就把桌面状态弄乱了。毕竟桌游除了桌面,人脑里还有一整套游戏状态,缺少实体会让大脑负荷要重得多。


谈点体外话。由于这款游戏规则相对繁杂,我尝试用 AI 辅助学习游戏规则,使用的 Gemini 。可惜这个游戏还太新,网上资料太少。导致 Gemini 对游戏规则细节知之甚少。但它又表现得很懂,对话中自信满满。我问了很多规则细节结果都是错的,即使我让它指出细节出至规则书上具体哪里,也全是幻觉。甚至引用论坛网友的讨论也能理解错误。最后,我还是得自己推敲规则书,或是用传统的搜索方法找到 bgg 论坛规则讨论版面的帖子,研读作者写的 FAQ 等等。和 AI 的问答阅读起来固然舒服,针对性很强(不像规则书读起来那么累),但我实在没有能力鉴别 AI 的错误。毕竟我原本就是因为不懂规则才去问的呀。

有些错误还是能看出来。毕竟我玩的游戏很多,可以从作者的游戏设计思路角度去考虑。玩的过程中有疑问去问 AI 。对反直觉(感觉游戏不应该这样设计)的答案有所警惕,可继续追问。但有些真看不出来。

比如我在和 bot 对战时,触发了一条 bot 需要 log 一艘飞船,我不知道该如何处理。(特地用英文术语)问了下 AI 。AI 告诉我应该把最近 bot 部署的 ship 卡 log 起来,并将同一地区的所有外派部队收回。但后一条是 AI 自己编的规则,我在规则书中怎么都找不到对应的文字。反复询问,AI 都表现的信誓旦旦。让我去查规则书某个章节(其实不存在)。它还引用了 BGG 论坛的帖子。而我仔细研读了大篇的帖子后,确定是 AI 混淆了 log 和 dismiss 的处理方法。

再有一例:游戏的舰长面板分 A/B 两面,供玩家选择。A 面只有一个任务,B 面有三个任务,其中一个和 A 面任务完全相同。完成 B 面的任务还有额外的 VP 奖励。我一开始非常不解,初看起来,A 面没有任何优势,因为 B 面不仅提供了 A 面的选择,有额外 VP ,还可以有更多选项。我在规则书上也没有找到选择 B 面的惩罚。带着这个问题我询问了 gemini ,它在搜索了 bgg 论坛后,又胡扯了一堆什么 A 面让玩家更专注,完成难度和行动奖励不同(实际完全一致的)。但实际上,核心差别其实是:B 面的科技/军事/影响力等导轨设计不同(我一开始没注意到,规则书里也没提这个差异),而 BGG 论坛里针对这点讨论的帖子中,下面好几条回复都强调了这一点,gemini 恁是在查看帖子后,把这条最重要的信息忽略掉了没告诉我。

结果,我和 AI 的这些对话并没有帮我节省理解规则的时间。不仅自己重新反复研究规则书,还花了更多时间去论坛看帖(当然这不是坏事)。我想,如果我让一个人类游戏玩家教我,若是自己没怎么玩这个游戏的话,都不会表现的如此自信吧。如何辨别 LLM 提供的信息中哪些确有价值会变得更加重要。LLM 的语言表达能力越来越强,也会变得越来越有欺骗性。

❌