Skip to content

综合案例:溶解效果

前面六节拆开了自定义材质的每个零件。这一节把它们组装成一个完整的效果:物体从边缘开始溶解,像被火焰吞噬一样逐渐消失,边缘发出橙色光芒。

这个效果用到了本章的全部知识:ShaderType uniform、#import 引擎模块、globals.time 动画、discard 丢弃像素、以及在 Rust 侧每帧更新 uniform 数据。

效果分析

溶解效果的原理很简单:

  1. 给每个像素生成一个噪声值(0 到 1 之间)
  2. 设定一个阈值(threshold),噪声值低于阈值的像素被 discard 丢弃
  3. 阈值附近的像素涂上边缘颜色(发光效果)
  4. 让阈值随时间变化——物体就"溶解"了

关键在噪声函数。GPU 没有 rand()——我们需要一个确定性的、只依赖 UV 坐标的伪随机函数。

Rust 侧

rust
use bevy::prelude::*;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{AsBindGroup, ShaderType};
use bevy::shader::ShaderRef;

fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            MaterialPlugin::<DissolveMaterial>::default(),
        ))
        .add_systems(Startup, setup)
        .add_systems(Update, animate_dissolve)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<DissolveMaterial>>,
) {
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(1.5, 1.5, 1.5))),
        MeshMaterial3d(materials.add(DissolveMaterial {
            params: DissolveParams {
                base_color: LinearRgba::new(0.1, 0.4, 0.8, 1.0),
                edge_color: LinearRgba::new(1.0, 0.5, 0.0, 1.0),
                threshold: 0.0,
                edge_width: 0.08,
                _padding: Vec2::ZERO,
            },
        })),
        Transform::from_xyz(0.0, 0.75, 0.0),
    ));

    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(-2.5, 3.0, 5.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    commands.spawn((
        DirectionalLight {
            illuminance: 10000.0,
            ..default()
        },
        Transform::from_xyz(1.0, 1.0, 0.5).looking_at(Vec3::ZERO, Vec3::Y),
    ));
}

#[derive(ShaderType, Debug, Clone)]
struct DissolveParams {
    base_color: LinearRgba,
    edge_color: LinearRgba,
    threshold: f32,
    edge_width: f32,
    _padding: Vec2,
}

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
struct DissolveMaterial {
    #[uniform(0)]
    params: DissolveParams,
}

impl Material for DissolveMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/dissolve.wgsl".into()
    }

    fn alpha_mode(&self) -> AlphaMode {
        AlphaMode::Opaque
    }
}

fn animate_dissolve(
    time: Res<Time>,
    mut materials: ResMut<Assets<DissolveMaterial>>,
) {
    let t = time.elapsed_secs();
    let cycle = (t * 0.3).fract();
    let threshold = if cycle < 0.5 {
        cycle * 2.0
    } else {
        2.0 - cycle * 2.0
    };

    for (_, material) in materials.iter_mut() {
        material.params.threshold = threshold;
    }
}

Listing 36-6:溶解效果——Rust 侧

运行:

console
cargo run -p ch36-shaders --example listing-36-06

方块会循环"溶解-恢复":阈值从 0 升到 1(溶解),再从 1 降到 0(恢复)。

DissolveParams 结构体

rust
#[derive(ShaderType, Debug, Clone)]
struct DissolveParams {
    base_color: LinearRgba,   // 物体本体颜色
    edge_color: LinearRgba,   // 溶解边缘的发光颜色
    threshold: f32,           // 溶解阈值:噪声 < threshold 的像素被丢弃
    edge_width: f32,          // 边缘发光带的宽度
    _padding: Vec2,           // 补齐到 16 字节对齐
}

两个颜色、两个控制参数、一个 padding——正好 48 字节(LinearRgba 是 16 字节,f32 是 4 字节,Vec2 是 8 字节),满足 GPU 的 16 字节对齐要求。

动画系统

rust
fn animate_dissolve(time: Res<Time>, mut materials: ResMut<Assets<DissolveMaterial>>) {
    let t = time.elapsed_secs();
    let cycle = (t * 0.3).fract();
    let threshold = if cycle < 0.5 {
        cycle * 2.0
    } else {
        2.0 - cycle * 2.0
    };
    for (_, material) in materials.iter_mut() {
        material.params.threshold = threshold;
    }
}

cycle 每 3.3 秒循环一次(t * 0.3 取小数部分)。前半段阈值从 0 升到 1(溶解),后半段从 1 降到 0(恢复)。三角波形状让溶解和恢复速度对称。

WGSL 侧

wgsl
#import bevy_pbr::{
    mesh_view_bindings::globals,
    forward_io::VertexOutput,
}

struct DissolveParams {
    base_color: vec4<f32>,
    edge_color: vec4<f32>,
    threshold: f32,
    edge_width: f32,
    _padding: vec2<f32>,
};

@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> params: DissolveParams;

fn hash(p: vec2<f32>) -> f32 {
    var p3 = fract(vec3<f32>(p.x, p.y, p.x) * 0.1031);
    p3 += dot(p3, vec3<f32>(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));
    return fract((p3.x + p3.y) * p3.z);
}

@fragment
fn fragment(mesh: VertexOutput) -> @location(0) vec4<f32> {
    let noise = hash(floor(mesh.uv * 80.0));

    if noise < params.threshold {
        discard;
    }

    let edge_dist = noise - params.threshold;
    if edge_dist < params.edge_width {
        let t = edge_dist / params.edge_width;
        let glow = params.edge_color.rgb * (1.0 - t) * 2.0;
        return vec4<f32>(params.base_color.rgb * t + glow, 1.0);
    }

    return params.base_color;
}

Listing 36-6 对应的 WGSL:溶解着色器

噪声函数

wgsl
fn hash(p: vec2<f32>) -> f32 {
    var p3 = fract(vec3<f32>(p.x, p.y, p.x) * 0.1031);
    p3 += dot(p3, vec3<f32>(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));
    return fract((p3.x + p3.y) * p3.z);
}

这是一个经典的 GPU 哈希函数——不保证密码学安全,但对视觉效果足够"随机"。输入是 UV 坐标,输出是 0 到 1 的浮点数。fract(取小数部分)和 dot(点积)的组合让微小的输入差异产生大幅的输出变化。

调用时传入 floor(mesh.uv * 80.0)——把 UV 空间划分为 80×80 的网格,每个格子内噪声值相同。这产生了"方块状溶解"的效果。如果你想要更细腻的溶解,增大网格分辨率;想要更粗犷的,减小它。

discard 与边缘发光

wgsl
if noise < params.threshold {
    discard;
}

discard 是 WGSL 的片段杀手——执行后这个像素完全不写入渲染目标,相当于"透明"。这是溶解效果的核心:阈值以下的像素直接消失。

wgsl
let edge_dist = noise - params.threshold;
if edge_dist < params.edge_width {
    let t = edge_dist / params.edge_width;
    let glow = params.edge_color.rgb * (1.0 - t) * 2.0;
    return vec4<f32>(params.base_color.rgb * t + glow, 1.0);
}

在阈值附近的窄带内(edge_width),基色和边缘色混合。越靠近溶解边界,t 越小,边缘色的权重越大——产生"边缘最亮、向内渐暗"的发光带。* 2.0 是增益系数,让发光更明显。

globals.time 的局限

注意这个例子没有用 globals.time——阈值动画完全在 Rust 侧完成。globals.time 的精度是 f32,一小时后归零。对于持续运行的游戏,用 Rust 侧的 Time 资源更可靠;globals.time 更适合"启动以来的短时动画"场景(如水波纹、呼吸灯)。

最终版

main.rslisting-36-06.rs 内容相同。运行:

console
cargo run -p ch36-shaders

这就是本章的最终成果:一个从零搭建的自定义材质管线——Rust 结构体定义数据布局,WGSL 着色器实现渲染逻辑,Material trait 把两者粘合在一起,uniform 每帧更新驱动动画。