标签 粒子系统 下的文章

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

Billboard节点是UnityShaderGraph中一个功能强大的顶点变换工具,专门用于实现面向相机的渲染效果。在实时渲染中,Billboard技术被广泛应用于粒子系统、植被渲染、UI元素和特效制作等领域,能够确保特定物体始终面向摄像机,从而提供最佳的视觉效果。

Billboard技术概述

Billboard技术源于计算机图形学中的精灵渲染概念,其核心思想是通过动态调整物体的朝向,使其始终面对观察者。这种技术在游戏开发中具有重要价值:

  • 在粒子系统中用于渲染烟雾、火焰、魔法效果等动态元素
  • 在开放世界游戏中用于优化树木和植被的渲染性能
  • 在UI系统中确保界面元素始终以正确角度显示
  • 在特效制作中创建各种视觉欺骗效果

UnityShaderGraph中的Billboard节点封装了这一复杂技术,让开发者能够通过可视化方式轻松实现面向相机的渲染效果,无需编写复杂的着色器代码。

节点端口详解

Billboard节点包含多个输入和输出端口,每个端口都有特定的功能和用途。

输入端口

Position OS端口接收物体空间的顶点位置数据。这个端口是Billboard变换的基础,提供了需要进行旋转的原始顶点坐标信息。在实际应用中,这个端口通常直接连接到顶点着色器的位置输出,或者与其他位置变换节点相连。

Normal OS端口处理物体空间的法线向量。法线数据对于光照计算至关重要,Billboard节点会对法线进行相应的旋转,确保光照效果在物体旋转后仍然正确。如果忽略法线变换,可能会导致光照异常或材质表现不正确。

Tangent OS端口管理物体空间的切线向量。切线主要用于法线贴图和某些高级着色效果,Billboard节点会同步旋转切线数据,保持与顶点和法线的一致性。在需要复杂材质表现的场景中,正确的切线变换尤为重要。

输出端口

Position输出端口提供旋转后的物体空间顶点位置。这是Billboard节点的核心输出,包含了经过相机对齐变换后的顶点坐标。这个输出通常直接连接到主节点的顶点位置输入,完成最终的顶点变换。

Normal输出端口返回旋转后的物体空间法线向量。变换后的法线确保了光照计算与物体新朝向的一致性,对于保持材质视觉真实性至关重要。

Tangent输出端口提供旋转后的物体空间切线向量。这个输出确保了法线贴图和其他依赖切线空间的着色效果能够正确工作。

控件参数解析

Billboard Mode是Billboard节点最重要的控制参数,决定了物体的对齐方式和旋转行为。

All Axis模式

All Axis模式实现完全相机对齐,物体的所有坐标轴都会与相机坐标系对齐。在这种模式下,物体会完全面向相机,类似于始终正对观察者的广告牌。

这种模式的特点包括:

  • 物体完全面向相机,保持正面朝向观察者
  • 所有轴向都会根据相机方向进行旋转
  • 适用于需要完全正面展示的效果,如粒子特效、公告板文字等
  • 在VR和AR应用中特别有用,确保UI元素始终面向用户

All Axis模式的一个典型应用场景是粒子系统中的精灵渲染。当相机移动时,每个粒子都会自动调整方向,始终以最佳角度面向观察者,从而保证视觉效果的一致性。

Around Y Axis模式

Around Y Axis模式提供受限的对齐方式,物体仅围绕Y轴旋转,保持Y轴方向不变。这种模式在保持物体部分方向稳定的同时,实现基本的面向相机效果。

这种模式的特点包括:

  • 物体围绕世界空间或物体空间的Y轴旋转
  • X轴和Z轴与相机对齐,但Y轴保持原有方向
  • 适用于树木、路灯等需要保持垂直方向的物体
  • 在开放世界游戏中广泛用于植被渲染优化

Around Y Axis模式在大型场景的性能优化中特别有用。通过将3D树木替换为Billboard四边形,可以大幅减少渲染负载,同时通过限制Y轴旋转保持视觉上的自然感。

技术实现原理

理解Billboard节点的内部工作原理有助于更好地使用和调试相关效果。

顶点变换矩阵

Billboard节点的核心是基于视图矩阵的逆向变换。本质上,它计算相机的旋转矩阵,然后将这个旋转应用于输入的顶点数据。在All Axis模式下,节点会提取相机的完整旋转矩阵;而在Around Y Axis模式下,则会提取并修改旋转矩阵,将Y轴分量重置为单位矩阵的Y轴。

数学上,这个过程可以表示为:

旋转矩阵 = 提取相机旋转矩阵
如果模式为Around Y Axis:
    旋转矩阵[1] = [0, 1, 0] // 重置Y轴
变换后位置 = 旋转矩阵 × 原始位置

法线和切线变换

法线和切线的变换遵循与位置数据相同的旋转逻辑,但由于它们是方向向量而非位置点,变换时不考虑平移分量。正确的法线和切线变换确保了光照和材质效果在Billboard变换后仍然保持视觉一致性。

法线变换需要特别注意,由于法线是协变向量,其变换矩阵通常为顶点变换矩阵的逆转置矩阵。但在Billboard这种纯旋转的情况下,由于旋转矩阵是正交矩阵,逆转置矩阵等于原矩阵,因此可以直接使用相同的旋转矩阵。

实际应用案例

Billboard节点在游戏开发中有多种实际应用,以下是一些典型场景。

粒子系统效果

在粒子系统中,Billboard技术是创建各种视觉特效的基础。

火焰和烟雾效果可以通过Billboard四边形配合透明度渐变纹理实现。每个粒子都是一个面向相机的四边形,使用噪声纹理和颜色渐变创建动态的火焰和烟雾外观。通过All Axis模式确保无论相机如何移动,效果都能正确显示。

魔法和能量场效果利用Billboard节点创建环绕角色的魔法光环或能量屏障。结合扭曲效果和发光着色器,可以制作出视觉上吸引人的魔法特效。Billboard确保这些效果始终面向玩家,提供最佳的视觉体验。

环境装饰优化

在大型开放世界游戏中,Billboard技术是性能优化的重要手段。

树木和植被渲染使用Around Y Axis模式的Billboard技术,将复杂的3D树木模型替换为简单的四边形,大幅减少三角形数量。当玩家距离较远时,使用Billboard树木;当玩家靠近时,逐渐淡入完整的3D模型。这种LOD(层次细节)策略在保持视觉质量的同时显著提升性能。

远处山脉和云层可以通过Billboard技术创建。使用多层Billboard平面配合透明度混合,可以模拟出具有深度感的远景效果。这种方法比使用完整3D模型更加高效,特别适合移动平台或性能受限的场景。

UI和交互元素

在用户界面和交互设计中,Billboard技术确保重要信息始终可见。

世界空间UI元素使用Billboard技术创建始终面向玩家的对话框、任务提示或交互图标。这在3D游戏中特别有用,玩家可以从任何角度都能清晰看到UI内容。

AR和VR应用中的界面元素通过Billboard技术确保虚拟界面始终面向用户,提供自然的交互体验。无论是信息面板、控制菜单还是虚拟标签,Billboard都能保证最佳的可读性和可用性。

性能优化考虑

使用Billboard节点时需要考虑性能影响,特别是在大量使用的情况下。

渲染性能

Billboard技术通过减少几何复杂度来提升性能,但顶点着色器的计算负载会增加。在移动设备或低端硬件上,需要平衡视觉质量和性能消耗。

优化策略包括:

  • 控制Billboard物体的数量,避免在同一帧中渲染过多Billboard
  • 使用LOD系统,根据距离动态切换Billboard和完整模型
  • 合并多个Billboard物体,减少绘制调用
  • 在性能敏感的区域使用更简单的Billboard效果

内存和带宽

Billboard通常使用简单的四边形几何体,这有助于减少内存占用和顶点数据传输带宽。但在使用高质量纹理时,需要注意纹理内存的消耗。

优化建议:

  • 使用纹理图集将多个Billboard纹理合并为一张大图
  • 根据距离使用不同分辨率的纹理
  • 压缩纹理格式以减少内存占用
  • 合理管理纹理的加载和卸载,避免内存峰值

常见问题与解决方案

在使用Billboard节点时可能会遇到一些常见问题,以下是相应的解决方案。

光照异常

问题描述:Billboard物体上的光照显示不正确,高光或阴影位置异常。

解决方案:

  • 确保正确连接Normal OS端口,并提供准确的法线数据
  • 检查Billboard模式是否适合场景需求
  • 在复杂光照环境下,考虑使用自定义光照模型或简化光照计算
  • 验证法线贴图是否正确应用,确保切线数据正确变换

深度排序问题

问题描述:Billboard物体与其他物体的深度排序错误,出现穿透或遮挡异常。

解决方案:

  • 调整渲染队列顺序,确保Billboard物体在正确的渲染阶段绘制
  • 使用Alpha混合时,注意透明物体的渲染顺序问题
  • 在粒子系统中使用软粒子技术缓解深度冲突
  • 考虑使用自定义深度偏移解决特定的排序问题

运动模糊和抗锯齿

问题描述:快速移动的Billboard物体可能出现运动模糊异常或抗锯齿效果不佳。

解决方案:

  • 在运动剧烈的Billboard物体上禁用运动模糊,或使用自定义运动向量
  • 调整抗锯齿设置,确保Billboard边缘平滑
  • 对于特别敏感的视觉效果,考虑使用更高分辨率的纹理
  • 在后期处理中应用特定的抗锯齿技术,如TAA(时间性抗锯齿)

高级应用技巧

掌握了Billboard节点的基本用法后,可以探索一些高级应用技巧。

自定义Billboard效果

通过组合Billboard节点与其他ShaderGraph节点,可以创建独特的视觉效果。

倾斜Billboard效果通过修改旋转矩阵,使Billboard物体以特定角度倾斜,而不是完全面向相机。这种效果可以用于创建更有动态感的粒子特效或风格化的视觉元素。

动态朝向Billboard根据游戏逻辑或玩家输入动态调整Billboard的朝向,而不是始终面向主相机。这种技术可以用于创建始终面向特定目标的效果,如追踪导弹的尾焰或指向任务目标的导航标记。

与其他系统的集成

Billboard节点可以与Unity的其他系统集成,创建更复杂的效果。

与VFX Graph集成,在视觉特效图中使用Billboard技术创建高性能的粒子效果。VFX Graph提供了更强大的粒子系统功能,结合Billboard可以实现电影级的视觉效果。

与Shader Graph高级特性结合,如曲面细分、几何着色器或光线追踪,创建更复杂的Billboard效果。这些高级技术可以增强Billboard的视觉质量,提供更逼真或更风格化的外观。


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

点赞留言

探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Matrix 首页推荐 

Matrix 是少数派的写作社区,我们主张分享真实的产品体验,有实用价值的经验与思考。我们会不定期挑选 Matrix 最优质的文章,展示来自用户的最真实的体验和观点。 

文章代表作者个人观点,少数派仅对标题和排版略作修改。


最近尝试用 Python 和 Matplotlib 从零手写复刻了一下 Pluribus 的片头。先看看效果:

1. 前言

最近看了 Apple TV 的一部剧叫 Pluribus。我很喜欢这部剧,原因有二:

  • 它核心概念里的 "Joining" 和 《EVA》里的 「人类补完计划」 非常像,很对我的胃口;
  • 剧情探讨了人类和 AI 的关系,也是我最近一直在深度思考的问题 <(")

除去剧情,我特别喜欢它的片头。极简但非常抓眼球,完全就是我的菜。Apple TV 的片头通常都很复杂且暗示剧情走向(比如《人生切割术》或者《羊毛战记》),但这一个很特别。这也是我第一次觉得「哎,这个我好像能用代码写出来」的片头 :>

2. 粒子系统 (Particle System)

因为我从来没碰过粒子系统,对计算机视觉也知之甚少,所以上手第一步就是先读几篇文章。下面这两个资源对拆解概念非常有帮助:

简单来说,我只需要一堆点,然后追踪它们的物理状态:位置、速度和加速度。

class Particle:
    def __init__(self, pos: (int, int), 
                 velocities: (int, int), 
                 accelerations: (int, int)):
        self.pos = pos
        self.vel = velocities
        self.acc = accelerations

套用高中物理学过的标准公式:

写个函数来更新这些值:

def pos_update(dot, dt):
    dot.pos = (
        dot.pos[0] + dot.vel[0] * dt,
        dot.pos[1] + dot.vel[1] * dt
        )
    dot.vel = (dot.vel[0] + dot.acc[0] * dt,
                dot.vel[1] + dot.acc[1] * dt)

对每个点跑这个循环,就能得到一个基础的粒子系统(渲染代码略过不表,不过这里有个很好的 matplotlib 动画教程)。

最后,给每个点加点随机力。假设质量(m)为 1,根据 F=ma,我们可以直接把随机值加到加速度上:

def force_apply(p: Particle):
    p.acc = (
        p.acc[0] + random.randint(-2, 2), 
        p.acc[1] + random.randint(-2, 2)
        )

def dots_update(dots, dt):
    for dot in dots:
        pos_update(dot, dt)
        force_apply(dot)
    return

初始化网格里的点之后,大概长这样:

3. 背景点 (Background-dots)

把片头看了五遍以后,我发现里面的点可以分为三类,各个击破:

  • 背景点 (Background-dots)
  • 圆圈点 (Circle-dots)
  • 文字点 (Text-dots)

对于背景点,简单的随机运动看着不自然。如果你仔细看(现在是第六遍了 :D),会发现它们之间是有交互的。基本上就是太近了会推开,太远了会拉近。我发现 Lennard-Jones 势能完美描述了这个行为:

简单说就是距离太近会排斥,距离远了(但在范围内)会吸引。就像下图这个曲线。(我是从这个博客学来的)。

实现起来也很简单,遍历每一对点应用这个力就行,复杂度是 O(n^2)。

def lj_force(p1, p2):
    dx = p1.pos[0] - p2.pos[0]
    dy = p1.pos[1] - p2.pos[1]
    dis = (dx**2 + dy**2) ** 0.5

    dx_dir = dx / dis
    dy_dir = dy / dis

    u = min(10, 4 * EPI * ((SIGMA/dis)**12 - (SIGMA/dis)**6))

    dx_acc = u * dx_dir / 1
    dy_acc = u * dy_dir / 1

    p1.acc = (p1.acc[0]+dx_acc, p1.acc[1]+dy_acc)
    p2.acc = (p2.acc[0]-dx_acc, p2.acc[1]-dy_acc)

加上 LJ 势能后的效果如下。能明显看到点之间相互作用产生的复杂运动。

4. 圆圈点 (Circle-dots)

加圆圈点之前,先快速复习一下如何在粒子系统中定义方向和距离。(记得的同学可以跳过 :O)

基本上给定一个角度 θ∈[0,2π) 我们可以得到方向的单位向量 ​dir_x=cos(θ) dir_y=sin(θ)​。给定两个点,我们可以得到从 p1​ 到 p2​ 的方向:

要得到方向(单位向量),我们用差值除以距离:

加圆圈点很容易。给个初始速度,按 2π(360度)均匀分布方向就行。

def add_wave(dots):
    for i in range(WAVE_DOTS_NUM):
        angle = 2 * math.pi * i / WAVE_DOTS_NUM
        
        pos = (WAVE_ORIGIN[0] + math.cos(angle)*5, 
            WAVE_ORIGIN[1] + math.sin(angle)*5)
        
        vx = WAVE_SPEED * math.cos(angle)
        vy = WAVE_SPEED * math.sin(angle)
            
        dots.append(Particle(pos, velocities=(vx, vy)))
  • 碰撞问题: 但这里有个坑。因为我们加了 LJ 力,背景点会和圆圈点互怼。圆圈扩大的时候,撞到背景点会被推歪,形状就散了。
  • 解决方案: 我的解法简单粗暴:给 Particle 类加个 mass(质量)属性。让圆圈点比背景点重得多,它们惯性就大,不容易被推跑。

更新物理计算遵循牛顿第二定律 (a=F/m)。基本就是更新速度的时候,把累计的力(加速度)除以质量:

def pos_update(dot, dt):
    dot.pos = (
        dot.pos[0] + dot.vel[0] * dt,
        dot.pos[1] + dot.vel[1] * dt
        )
    dot.vel = (
        dot.vel[0] + dot.acc[0] * dt / dot.mass,
        dot.vel[1] + dot.acc[1] * dt / dot.mass
        )

对比一下(左:无质量,右:有质量)。

加了质量以后看着舒服多了吧?能明显看到圆圈点把背景点推开,自己还能保持队形。

5. 文字点 (Text-dots)

用点渲染文字不难。找个字体(我用的 Arial)画出来,然后提取像素位置就行。

def get_text_draw(text = TEXT, font_path = FONT_PATH):
    mask_img = Image.new("L", (WIDTH, LENGTH), 0)
    draw = ImageDraw.Draw(mask_img)
    font = ImageFont.truetype(font_path, 35)

    bbox = draw.textbbox((0, 0), text, font=font)
    text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
    draw.text(((WIDTH - text_w) // 2, (LENGTH - text_h) // 2 - 5), text, fill=255, font=font)
    y_coords, x_coords = np.where(np.array(mask_img)[::-1] > 128)
    return x_coords, y_coords

难点在于做那个「指纹」图案。仔细看原片,它像个波浪,稍微有点不规则。为了简单,我用 sine wave 模拟:

基本上就是根据距离中心的远近推拉这些点。调整频率能搞出不同的环形图案。下图是 freq={1,4,7} 的效果。

def set_fingerprint(x, y, freq = RADIAL_FREQ, strengh = RADIAL_STRENGTH):
    dx = x_coords - RADIAL_ORIGIN[0]
    dy = y_coords - RADIAL_ORIGIN[1]

    dist = np.sqrt(dx**2 + dy**2)
    angle = np.arctan2(dy, dx)

    push = np.sin(dist * freq) * strengh

    x_new = x_coords + (np.cos(angle) * push)
    y_new = y_coords + (np.sin(angle) * push)
    return x_new, y_new

如下是从点 P(25,42) 发起正弦波应用到文字的效果。

其实调这个波的参数花了我好久。试了各种组合,最后选了个看着最舒服的。^_^

把所有东西合在一起,就有了第一版片头!8)

6. 性能优化 (Performance Optimization)

先停一下。目前渲染60帧要跑6分钟。感觉我在浪费生命等它跑完 :( 是时候做点优化了。

6.1 空间哈希 (Spatial Hashing)

前面说了,瓶颈在物理交互计算,复杂度 O(n2)。加上文字点和不断生成的圆圈点,数量轻松上千,意味着每帧要做 10^6 次距离检测。

我的解法是用空间哈希(分桶),把空间划成网格,只计算相邻网格里粒子的 LJ 力。灵感来自第 3 节的公式:距离 ≥3σ 时势能几乎归零。

我用哈希表记录每个点属于哪个格子:

def _bin_coords(pos):
    return int(pos[0]) // BIN_SIZE, int(pos[1]) // BIN_SIZE

def _build_bins(dots):
    bins = {}
    for idx, p in enumerate(dots):
        bx, by = _bin_coords(p.pos)
        if 0 <= bx < BIN_XNUM and 0 <= by < BIN_YNUM:
            bins.setdefault((bx, by), []).append(idx)
    return bins

这一改,速度提升了 5 倍,渲染时间从 6 分 10 秒降到了 1 分 06 秒。

(虽然我知道用树结构——类似二叉索引树——动态维护位置能把复杂度降到 O(nlogn),毕竟最近在刷 LeetCode。但网格法目前够用了。)

6.2 生命周期管理

另一个优化是控制点的生命周期。圆圈点飞出屏幕(「越界」)后就不用算了。我加了个定期清理。这对减少内存占用很有效,之前内存都飙到 10GB 了。

def prune_dots(dots, circles, margin=50):
    alive_dots = []
    alive_circles = []

    for dot, circle in zip(dots, circles):
        x, y = dot.pos
        if -margin < x < WIDTH + margin and -margin < y < LENGTH + margin:
            alive_dots.append(dot)
            alive_circles.append(circle)
        else:
            circle.remove()

    dots[:] = alive_dots
    circles[:] = alive_circles

我很确定用内存池(链表+哈希表)能做到 O(1) 的插入删除,但对于这个项目有点杀鸡用牛刀了 :/

7. 视觉打磨 (Visual Optimization)

接下来打磨一下视觉效果。

7.1 文字形状

第一个问题是文字时间长了会「糊」掉或者散架。因为点挤得太紧,LJ 势能把它们推开了,导致我们(搞了半天的)指纹纹理丢了。

解决办法很简单:加个锚点力 (Anchor Force)。就像个弹簧,点飘太远了就把它拽回原位。我还加了点阻尼(摩擦力)防止它震荡个没完。

def anchor_force(p):
    dx = p.anchor[0] - p.pos[0]
    dy = p.anchor[1] - p.pos[1]
    dis = (dx**2 + dy**2) ** 0.5
    dx_dir = dx / dis
    dy_dir = dy / dis

    f = dis * ANCHOR_STRENGH

    damping_fx = -p.vel[0] * DAMPING
    damping_fy = -p.vel[1] * DAMPING

    p.acc = (
        p.acc[0] + (f * dx_dir + damping_fx) * random.randrange(5, 10) / 10, 
        p.acc[1] + (f * dy_dir + damping_fy) * random.randrange(5, 10) / 10
        )

7.2 呼吸与循环

另一个改进是给背景点加个「呼吸」效果,大小有节奏地缩放。给每个粒子加个相位属性,用正弦波更新就行。

最后,为了防止背景点飞出屏幕,我做了个屏幕循环 (Screen wrapping)。点从右边出去,就从左边回来。

def pos_update(dot, dt):
    dot.pos = (
        dot.pos[0] + dot.vel[0] * dt,
        dot.pos[1] + dot.vel[1] * dt
    )
    dot.vel = (
        dot.vel[0] + dot.acc[0] * dt / dot.mass,
        dot.vel[1] + dot.acc[1] * dt / dot.mass
    )
    dot.acc = (0, 0)

    dot.phase = (dot.phase + PHASE_INCREMENT) % (2 * math.pi)
    sine_wave = (math.sin(dot.phase) + 1) / 2

    if dot.type == 0:
        ## Keep background dots
        dot.vel = (dot.vel[0] * DECAY_RATIO, dot.vel[1] * DECAY_RATIO)
        dot.pos = (dot.pos[0] % WIDTH, dot.pos[1] % LENGTH)
        ## Change their size periodically
        dot.radius = 0.5 * (0.4 + 0.6 * sine_wave)
    else:
        dot.radius = 0.5 * (0.9 + 0.1 * sine_wave)

效果图解:

当然你也可以把文字换成任何你想要的:

8. 总结

这其实是我第一次尝试写粒子系统。本来计划在剧终(圣诞节)前搞定,但我高估了自己旅行时的精力和专注度。说实话,理解原理并实现它确实花了我不少时间。

相比之下,我看很多人用 Gemini 生成那种酷炫的 web 端 3D 粒子系统。跟那些比,我这个可能显得简陋甚至有点「丑」。但对我来说,从零构建的这个过程要更 enjoyable,虽然这肯定不是最高效的方法。最后,我觉得这种感觉大概也就是《Pluribus》想表达的东西吧。 :V