Skip to content

bevy_color 调色间

颜色这个东西,全书已经用了十四章——Color::srgb(0.85, 0.2, 0.2) 这样的三元组写了不下百次,但一直没问过两个要紧的问题:这三个数到底是什么意义?为什么有时候两个好看的颜色混出来一团脏?《渡口夜话》是夜戏,灯光、月色、水面全靠调色撑,是时候把 bevy_color 这个 crate 系统过一遍了。

Color 是十种色彩空间的联盟

Color 不是一个“红绿蓝结构体”,而是一个枚举——同一个颜色概念,可以用十种色彩空间(color space,描述颜色的坐标系)中的任意一种来记账:

变体坐标含义拿手好戏
Srgba红、绿、蓝(显示器伽马)直接对接美术给的色号、十六进制
LinearRgba红、绿、蓝(物理线性)渲染器内部运算的口径
Hsla / Hsva / Hwba色相、饱和度、亮度/明度/白黑“转个色相”“降点饱和”这类直觉调整
Laba / Lcha感知均匀的明度与色度(CIE Lab 系)颜色差异的度量
Oklaba / Oklcha更现代的感知均匀空间混色、渐变、明暗调整的首选
XyzaCIE XYZ各空间互转的中转站

构造函数与变体一一对应:Color::srgb(r, g, b)Color::hsl(h, s, l)Color::oklch(l, c, h)……写哪个,账就记在哪个空间里。每个空间也是独立的类型(SrgbaOklcha……),实现了互相 From/Into,随时换算;美术给的色号还能直接解析——Srgba::hex("#C83A3A")

为什么要十种?因为不同的操作在不同的空间里才自然。这不是抽象玄学,做个实验立刻见血——同一对颜色(靛蓝与金黄),分别在 SrgbaOklcha 空间里直线混合十一档:

rust
// 第一排:在 sRGB 空间里从靛蓝直线走到金黄——中段又灰又脏
    // 第二排:换 Oklch 空间走同一趟——一路干净
    let indigo = Srgba::new(0.10, 0.25, 0.90, 1.0);
    let gold = Srgba::new(1.00, 0.84, 0.20, 1.0);
    for i in 0..11 {
        let f = i as f32 / 10.0;
        let x = i as f32 * 100.0 - 500.0;
        swatch(&mut commands, x, 250.0, indigo.mix(&gold, f));
        swatch(
            &mut commands,
            x,
            160.0,
            Oklcha::from(indigo).mix(&Oklcha::from(gold), f),
        );
    }

Listing 15-10(节选一):同一趟渐变走两条路——Mix trait 在谁身上调用,就在谁的空间里插值(examples/listing-15-10.rs)

console
cargo run -p ch15-sprites --example listing-15-10
text
小棠:sRGB 直线的中点是 #8C8B8C,又灰又闷。
小棠:Oklch 路线的中点是 #00BF9D,干净多了。
小棠:阿燕戏服的色号 #C83A3A,换算成色相是 0°。

色卡墙六排:sRGB 混色那排中段发灰发脏,Oklch 那排一路鲜亮;下面依次是十二色的色相轮、金色的明暗阶梯、十张现成色票、以及一排逐渐透明的红色卡压在金条上

Figure 15-12:色卡墙——第一排(sRGB)中段塌成灰绿,第二排(Oklch)始终干净,这就是空间选错的代价

第一排中点 #8C8B8C——纯灰。原因:sRGB 的三个数字是给显示器看的电信号,不是给眼睛看的感知量,在它上面做算术,蓝降黄升的半路恰好撞进低饱和区。Oklcha感知均匀空间——数字上走直线,眼睛看着也是匀速,混色、渐变、过渡动画一律建议先转过去再算。晨昏天色渐变、血条由绿转红,全是这个道理。

操作颜色的工具是四个 trait,Color 与所有空间类型都实现了(操作在当前空间没定义时,自动借道 Oklcha 算完再转回来):

  • Mix——a.mix(&b, 0.5),按比例插值,刚才已经领教;
  • Hue——rotate_hue(度数) 转色相。色卡墙第三排把一块基色转了十二次,每次 30°,一只调色轮转出来(Listing 15-10 的 hue 片段):
rust
// 第三排:一块基色转 12 次色相,一只调色轮
    let base = Hsla::new(0.0, 0.85, 0.55, 1.0);
    for i in 0..12 {
        let x = i as f32 * 100.0 - 550.0;
        swatch(&mut commands, x, 60.0, base.rotate_hue(i as f32 * 30.0));
    }
  • Luminance——darker(量)lighter(量),加减的是绝对亮度。第四排拿灯笼金往两头各打四档。注意亮度是有底的:一个本就深的颜色减不了多少就触底变黑(拿阿燕的戏服红试试就知道——它的相对亮度只有 0.155 左右);
  • Alpha——with_alpha(0.3) 改不透明度。第六排把朱红一路调透,压在金条上看“透”得多均匀。上一节字幕框、月亮光晕的半透明都是它。
rust
// 第四排:灯笼金往两头打——darker 四档、原色、lighter 四档
    let gilt = Color::srgb(0.93, 0.74, 0.29);
    for i in -4i32..=4 {
        let x = i as f32 * 100.0;
        let color = if i < 0 {
            gilt.darker(-i as f32 * 0.07)
        } else {
            gilt.lighter(i as f32 * 0.07)
        };
        swatch(&mut commands, x, -40.0, color);
    }

Listing 15-10(节选二):色相轮与明暗阶梯——Hue 与 Luminance 两个 trait 的手感(examples/listing-15-10.rs)

现成色票

不想凑数字时,bevy::color::palettes 里有三本印好的色票:css(一百多个 CSS 标准命名色:css::GOLDcss::CRIMSONcss::REBECCA_PURPLE……)、tailwind(Tailwind 的色阶系统:tailwind::SKY_400tailwind::EMERALD_500,数字越大越深)、basic(十六个老牌基本色)。它们都是 Srgba 常量,凡是收 impl Into<Color> 的地方直接塞:

rust
// 第五排:三本现成色票——CSS 命名色、Tailwind 色阶、八只基本色
    let named: [Srgba; 10] = [
        basic::AQUA,
        basic::NAVY,
        css::GOLD,
        css::CRIMSON,
        css::REBECCA_PURPLE,
        css::SEA_GREEN,
        css::TOMATO,
        tailwind::SKY_400,
        tailwind::EMERALD_500,
        tailwind::AMBER_300,
    ];
    for (i, color) in named.into_iter().enumerate() {
        swatch(&mut commands, i as f32 * 100.0 - 450.0, -150.0, color);
    }

Listing 15-10(节选三):三本色票各抽几张——Srgba 常量直接当 Color 用(examples/listing-15-10.rs)

原型期用色票,正式美术定稿后换 Srgba::hex 对色号——两步都不用自己凑三元组。

Srgba 与 LinearRgba:一对容易踩的双胞胎

最后说清那对最容易混淆的变体。Srgba 的数值经过了伽马编码——它描述“显示器该发多亮的信号”,数值与人眼感知大致成正比;LinearRgba 是物理线性的光强,渲染器做光照、混合、叠加时一律用它(GPU 收到的就是这个,转换由引擎代劳)。日常写游戏逻辑你几乎只碰 Srgba 系的构造函数;但有两个场合要留心:

  • 见到 API 明确收 LinearRgba(自定义着色器、第 36 章的材质字段),用 color.to_linear() 换算,别把 sRGB 数值直接塞进去——颜色会整体偏亮或偏暗;
  • 反过来,从渲染管线里读出的颜色想给美术对色号,先 to_srgba()to_hex()

数值大于 1 的颜色Color::srgb(4.0, 4.0, 0.5) 不报错——HDR(高动态范围)管线里“比白更亮”是合法的,配合 Bloom 泛光会真的发光。这扇门等第 26 章打开。