🌻 设计文档小农场 · 野花笔记
← 全部文档/野花笔记/美术

美术规范

现行 #美术#风格#序列帧#资源规范 docs/野花笔记/美术规范.md
这份规范是"反推"出来的,不是选的

用户给了一张参考图(就是下面这张),内容是一条植物装饰带上的线稿小白兔。 下面所有数值都是从那张图的像素里量出来的,不是凭印象写的。量法见第一节。

和「小农场」的 美术风格(日本清新风)是两条不同的线,不要混用(见第七节)。


一、参考图是什么,以及为什么重做过一次#

这份规范的第一版是错的,整份按第二张参考图重写

第一版参考图是一把机械键盘,我在它最下方那条植物装饰带上量了色板(旧图还留在 art/ref-source.jpg)。按那套做出来的东西,用户看到实物图之后的原话是: 「美术风格完全不对啊,你看看图片中的样式。」 —— 并对,因为第一版有两个硬伤:

硬伤 实测证据
画面整体亮了一档 旧版小场景平均明度 0.747、暗部(<0.35)占 0.0%;新参考图平均 0.601、暗部占 9.9%
同一画面里有两张参考图的血统 旧图是"键盘键帽上的小兔子"(一张实拍照片),新图是装饰花纹边框(一张设计稿)。按实拍照片取色,取到的是照片的环境色;按设计稿取色,取到的才是作者真正用的色

结论:画风基准必须取自设计稿,不能取自实拍照片。 照片里的颜色是"被拍到的颜色", 不是"被画出来的颜色",中间隔着环境光、白平衡和相机。 这一条比任何具体色号都重要。

1.1 新参考图(现行基准)#

新参考图 · 灰蓝装饰花纹边框(左花叶 / 中央兔子 / 右花叶)
新参考图 · 灰蓝装饰花纹边框(左花叶 / 中央兔子 / 右花叶)
项 实测
载体 一条横向装饰花纹边框,918×133,宽高比 6.90
内容 从左到右:花朵 → 叶片 → 中央的兔子 → 右侧花叶(视觉识别原话:"一条偏暗色调的装饰花纹边框,画面从左到右主要是花朵、叶片、中央的兔子和右侧花叶")
背景 深色。三段底色分别是 #222328 / #3D4756 / #5F6B76(左→右逐渐变浅)
描边 有,深灰,约 1~2px,不是纯黑
上色 平涂为主 + 轻微明暗,带纸面纹理

1.2 色板怎么量出来的#

不是凭感觉调的,是量化 + 直方图两层量出来的:

# ① 24 阶中值切分量化 → 按面积排序拿主色
pal = im.quantize(colors=20, method=Image.MEDIANCUT).convert('RGB')

# ② 明度直方图:确认画面的**重心**落在哪一档(这一条决定了"亮一档"这种硬伤)
g = np.asarray(im.convert('L')).astype(float) / 255
for i in range(10):
    print(i/10, ((g >= i/10) & (g < (i+1)/10)).mean())

# ③ 取最暗的 3% 和最亮的 3%,分别拿"描边色"和"高光色"

实测结果 —— 明度直方图的峰值在 0.5~0.6(占 25.5%):

明度档 占比 参考图实测色值
0.1~0.2 1.9% #2A2B2D
0.2~0.3 4.5% #313234 ← 描边色
0.3~0.4 7.6% #4C4C4F
0.4~0.5 13.4% #6E6E74
0.5~0.6 25.5% ← 峰值 #7D818C / #888890
0.6~0.7 17.5% #8E9099 / #9C9A9E
0.7~0.8 11.1% #BCB7B5
0.8~0.9 8.8% —
0.9~1.0 9.5% #F8F1EB
三条从直方图里读出来的硬结论
  1. 最暗只到 #2A2B2D,没有纯黑。 但暗部(<0.35)确实占 9.9% —— 所以"不要纯黑"和"必须有暗部"这两件事同时成立,不矛盾。 旧版把"不要纯黑"执行成了"整幅不用深色",暗部直接归零,这才是错的地方。

  2. 峰值在 0.50.6,不是 0.70.8。 画面重心在中间调。 旧版重心在 0.75,整幅亮一档。

  3. 全图主色 #8E9099 饱和度只有 7.2%,#9C9A9E 只有 2.5%。 结论:新图是中性灰,不是"蓝灰"。

    [!danger] 上面这句结论读错了,已在 2.0 纠正 用"全图统计"读一张元素只占 18.4% 的图,读到的是背景纸底的颜色, 不是作者用的颜色。按元素口径重量: 有色元素色相 P50 = 220°(正蓝灰)、饱和 P90 = 23.2%、色度 P50 = 12。

    所以它就是蓝灰,而且比我当时以为的艳得多。 这个误读直接导致了第二版「#8E9099 中性灰」色板, 也就是用户说的「灰度过重、对比不足」。 教训:统计口径选错,会把"蓝灰"读成"中性灰",然后照着错的基准做一整套素材。


二、色板(按新参考图实测,这一版是按"色度"定的)#

这一节换过**三次**,前两次的错误都不一样,都记在这里
版本 主色 问题 用户看到的结果
第一版 #A8ACB5 取自旧参考图(机械键盘实拍照片) —— 取到的是"被拍到的颜色" 「美术风格完全不对啊」
第二版 #8E9099 取自新参考图,但我把它读成了「中性灰」 「灰度过重、对比不足」
第三版(现行) #8C97A8 按实测色相 220° / 色度恒定重做 —

第一版错在取材,第二版错在判读。 第二版的失误在于: 我只量了参考图全图的主色(#8E9099,饱和 7.2%), 却没量元素内部的色相分布。分区域一量就很清楚:

2.0 参考图到底"是什么色"(实测,这一节的地基)#

把参考图按元素(与高斯模糊差异 > 8 的像素)挑出来,只统计有颜色的部分:

量 实测值 说明
有色元素的色相 P50 220° 正蓝灰,不是中性灰
有色元素色相 P10 / P90 27° / 240° P10 是暖色(花心那撮)
有色元素饱和 P50 / P90 12.1% / 23.2% 比我第二版整幅的 3.9% 高得多
元素内色度(max−min)P50 12 ——

最关键的一条:把参考图按明度分 8 档,每档都量色度:

明度档 0.1~0.2 0.2~0.3 0.3~0.4 0.4~0.5 0.5~0.6 0.6~0.7 0.7~0.8 0.8~0.9
色度 P50 7 5 11 14 12 11 11 8
色度是**恒定**的 —— 这就是"灰度过重"的几何原因

参考图的色度在各明度档基本不变(P50 都在 11~14)。意思是它的画法是 「每个明度档都有自己的色」,不是「越暗越往灰里退」。

而我第二版的色板色度是 11 → 15 → 8 → 6 → 4 → 3 → 3 —— 暗部全退成了中性灰。所以整幅看着发灰、发闷,而每一项检查都写着 ✓。

修正做法:色相钉在 215~225°,色度逐档保持;暗色也要有颜色,不许退成灰。

[!danger] 顺带纠一个判据错误:对暗色不能用 HSV 饱和度做上限 HSV 饱和 = (max−min)/max,max(明度)越小,分母越小, 于是暗色的 HSV 饱和会自动虚高。 实测:#292D35 明度只有 0.18、HSV 饱和 24.5%,看着"超标 4.5 个点", 但它的色度只有 12 —— 和参考图暗部完全一致。 拿 HSV 饱和当暗色的上限,会逼着我把本来就对的暗色改坏。 判"有没有颜色"用色度,判"艳不艳"才用饱和度。

2.1 色板(现行,tools/art/notes_art.py 的 P)#

档 色值 色度 明度 用在
亮 #F6F1EC 纸白 10 0.95 兔子身体、花瓣、纸底
#EDE8E5 暖白 · #DFDFE6 雾 · #D3D6DE 浅 8/7/11 0.91/0.88/0.84 受光面、远景、云
中间调 #B8BCC6 · #9FA5B2 14/19 0.74/0.65 中景、灰面
主体 #8C97A8 ★ 主色(色相 216°) · #808D9F 28/31 0.59/0.55 画面主体
暗 #878C98 · #6E7480 · #5D6370 · #4C525D 17/18/19/17 0.55/0.45/0.39/0.32 暗面、地面、中景剪影
最深 #32373F ← 描边色 · #292D35 13/12 0.21/0.18 所有轮廓线、最深的暗部
三条不许违反
  1. 最暗到 #292D35 为止,没有纯黑。(参考图暗部占 9.9%)
  2. 主色是偏蓝的灰蓝 #8C97A8(色相 216°),不是中性灰。 想压冷就往蓝灰加,不要把颜色抽掉变成灰 —— 抽掉颜色就是第二版的老毛病。
  3. 每个明度档都要有色度(≥12)。暗部退成中性灰 = 画面发灰,这是硬禁。

[!important] 饱和度与色度是两级约束,别混成一个数

对象 判据 上限/下限 依据
色板色号 色度 8 ~ 31 参考图元素内色度 12~28
成品像素 饱和 P99.9 ≤ 30% 参考图自己 P99.9 = 32.3%
场景整幅 饱和 P90 ≤ 22% 参考图实测 15.2%
场景整幅 色度 P50 ≥ 13 拦"太灰"。旧版 6 / 新版 15~22 / 基准 12

[!danger] 这条阈值我定错过两次,两次都是比基准还严或比基准还松 第一次(第二版):饱和度上限写 10%,而基准图元素内是 16.2% —— 于是我的场景只有 3.9%(基准的 1/4、用户的评价是"灰度过重")却一路显示 ✓。 第二次(本轮):只加"饱和上限",没加"色度下限" —— 但饱和上限拦不住"太灰",它只拦"太艳"。

规矩:拦"太灰"必须有一条下限判据,而下限必须能被旧版触发。 判据是否有效,用三个已知样本验:旧版色度 6(必须拦下)/ 新版 15~22(必须通过)/ 基准 12。三个一眼可分,判据才算立住。 色度下限取 13 比基准的 12 还高一档是有意的:基准是装饰边框(元素占 73%、 几乎全是笔触),游戏场景大部分是平涂天空,色度天然被拉低,套同一个下限两边都不合适。

2.2 描边色#

名称 色值 明度 用在哪
墨灰 #32373F 0.21 所有外轮廓
次墨灰 #4A4F5A 0.29 内部结构线、浅一档的轮廓
描边用哪个,看"这条线是外轮廓还是内部细节"

外轮廓用 #32373F(最深),内部结构线用 #4A4F5A。 两者不能一样重 —— 全画同一种线重,会让身体像"被黑线切成零件"(见第三节)。

2.3 暖色(只出现在"芯"和"果"上)#

名称 色值 用在哪
暖金 #C4A05E 极稀有植物的花心 —— 唯一允许的高饱和色,面积 < 3%
浅粉棕 #BCAEA6 兔子内耳
暖色例外必须**同时**过两条

① 色值在容差 ±22 以内(水彩后期会把颜色挪 10~15 个数,容差太紧会误判); ② 面积 < 3%。检查脚本两条都量,面积是独立量的。

极稀有植物不能做成"整朵金花",暖金只能进花心。 实测极稀有那一种暖色面积 1.08%。

2.4 植物的稀有度色相#

稀有度靠色相拉开,不靠明度。分布与 收集手册 二一致: 常见 18 / 少见 8 / 稀有 3 / 极稀有 1。

稀有度 花瓣色 种数 色度 备注
常见 #94979F 灰蓝 18 11 与主色同族
少见 #728C74 苔绿 8 26
稀有 #8D9CB4 野花青 3 39 色度偏高,靠混色压回;成品 P99.9 实测 20.0%,在 30% 以内
极稀有 花瓣仍是灰蓝,花心是暖金 1 — 暖金 52% 靠暖色例外放行

三、造型:那只兔子#

3.1 新参考图的视觉识别结果(照抄,不要"发挥")#

对着新参考图中央那只兔子,视觉模型的描述:

兔子是白色偏米色、带浅棕色斑纹的卡通造型,整体有细深色描边。 耳朵很长,竖直尖耳,内耳为浅粉棕色;眼睛画成黑色小椭圆。 身体圆润,直立坐着,前腿垂在胸前,后腿收拢;尾巴基本看不见。

部位 特征 实现
毛色 白色偏米色 + 浅棕斑纹 底 #F8F1EB;背上 3 块浅棕斑
耳朵 很长、竖直、尖;内耳浅粉棕 leaf_shape 画叶片形(根部窄、中间宽、顶部尖),长度 25/29px
眼睛 黑色小椭圆 2.5×3.3 实心椭圆,不加高光点
身体 圆润 竖向胶囊,30×34px
四肢 前腿垂在胸前、后腿收拢 侧面跑姿需要改,见 3.3
尾巴 基本看不见 半径 5px 的小球
描边 有,细深色 见 4.1「连续外轮廓」

3.2 这条兔子改了三轮,每轮改的都不是颜色#

第一轮:只改了颜色和描边 → **没用**

拿并排图给专业复核,原话是: 「右边和左边的核心差异:左边是手绘感、细线、柔和明暗、竖向兔子造型; 右边是粗描边、硬边、局部平涂、横向机器人造型。」 并给出「右边最该改的三点」,顺序是: ① 先改成竖直、完整、像兔子的身体轮廓;② 补出两只宽而尖的兔耳; ③ 减轻统一粗黑描边,把阴影从"零件灰块"改成连续的体积明暗。 注意:三点里没有一条是"换颜色"。

轮次 改了什么 复核结论
一 换色板 + 加描边 "像四足小机器人 / 关节玩偶"
二 竖向躯干 + 叶片形耳 + 加明暗 "还是'兔子头部特征 + 机械身体'"
三 改成"整体剪影 → 连续外轮廓 → 内部细节" 暗部 0.2% → 10.9%(参考图 8.2%),明度 0.894 → 0.666(参考图 0.584)
真正的病根:**逐零件描边**

第二轮复核的关键一句是: 「不是描边重,是关节分块把身体切开了 —— 头/颈/胸像几个球体堆在一起, 前后腿都有明显的分段和独立块面,像机械接口。」

所以第三轮把做法从"给每个零件描边"改成 "把所有零件画成一张实心剪影 → 由剪影生成一条连续外轮廓 → 再叠内部细节"。 这样头、脖子、胸口、腿之间不会再有闭合的深色圈线 —— 分件感的根源就是那些内部圈线,而不是线宽。 实现见 tools/art/notes_art.py 的 draw_rabbit() 与 outline_pass()。

3.3 坐姿 vs 跑姿#

参考图里是坐姿,但游戏里的主角要跑。所以:

  • 坐姿:直接照参考图(用于立绘、手账封面、主视觉)
  • 跑姿:在坐姿的基础上改成侧面偏四分之三,只允许动腿和身体倾角, 耳朵、眼睛、内耳、斑纹、描边规则一律不许变
兔子立绘 · 按新参考图风格生成
兔子立绘 · 按新参考图风格生成
跑姿参考 · 侧面偏四分之三 · 同一份色板与连续轮廓规则
跑姿参考 · 侧面偏四分之三 · 同一份色板与连续轮廓规则

3.4 四条不许动的#

  1. 耳朵是叶片形,不是细圆柱。 细圆柱画出来是"天线"(第一轮就是这个毛病)。
  2. 眼睛是实心小黑椭圆,不加高光点。 加了高光就变成另一只兔子了。
  3. 描边只在外轮廓上。 内部不许有闭合的深色圈线 —— 那是"零件感"的来源。
  4. 背上有浅棕斑纹。 这是新参考图的特征,去掉就变成一只普通白兔了。

四、线条、上色与纸感#

4.1 线条:连续外轮廓,不是逐零件描边#

元素 原生 1080 下线宽 渲染 540 下线宽 颜色
外轮廓(所有主体) 3~4px 1.5~2px #32373F
内部结构线 2px 1px #4C4C4F(必须比外轮廓浅)
小图标(单朵花、手账格) 2px 1px #4C4C4F
极小(花蕊、粒子) 不描边 — —
外轮廓必须由**整体剪影**生成,不能逐零件描边

病根解释见 3.2。做法是 outline_pass():

① 把主体所有部件画成一张**实心剪影**(只看 alpha,不看颜色)
② 剪影膨胀一圈、取差集 → 得到**一条连续外轮廓**
③ 轮廓柔化 0.6px(去硬锯齿),贴上去
④ 最后画内部细节(**细节不带外描边**)

这样头、颈、胸、腿之间不会出现闭合的深色圈线。 那只"像机械接口"的分件感,来源就是这些内部圈线。

[!warning] 线宽必须写明在多大画布上量 只写"3px"是没用的:同一条线在 540 宽的图上和 1080 宽的图上差一倍。 这份的规矩是:先在 1080×1920 上量,再整体缩到 540×960, 而且缩放只对最终合成帧做一次,不许每个零件各自缩放(接缝会各糊一圈)。

4.1.1 毛发边缘要有轻微起伏#

完美椭圆 + 粗描边 = "玩具感"。用 fur_ellipse() 把轮廓做成 1px 级的自然起伏多边形,看起来才像手绘的毛边。

4.2 上色#

这套风格的核心是"平":

规则 数值
填充 平涂,同一块颜色内部明度变化 ≤ 6%
明暗 最多两级(本色 + 一级暗面),没有环境光、没有反射光
暗面位置 统一在右下(光从左上来)
边缘 轮廓处柔化 1~2px(渗化),平涂区严格不柔化,见 4.4
纹理 全图压一层结构化纸纹(不是逐像素噪点),见 4.4

4.4 水彩后期:四个参数与实测#

上面说"这套风格的核心是平"——那指的是色块内部要平。 但整幅图如果只有平涂,出来的就是扁平插画,不是水彩。 实测复核原话:"更接近低饱和扁平插画,水彩感中等——轮廓整齐、块面化, 缺少水彩渗化、笔触方向和纸张吸水边缘"。所以平涂之后还有一道后期:

参数 作用 做法 实测贡献
bleed 边缘渗化 只在轮廓附近混一层模糊(用边缘 mask 限定范围) 强边 31310 → 29010
edge 吸水沉积边 沿轮廓压一圈略深的 #737782 色数 +46,加一圈深边
mottle 透明叠色(水渍) 撒极淡的同色椭圆斑块,模糊 9px 柔化边 3%
grain 纸纹纤维 多尺度:粗纤维(低分辨率噪声放大)+ 细颗粒,只动亮度不动色相 噪点幅度 3.42 → 1.53
这里有两个坑,都是**量出来才发现的**,不看数据绝对发现不了

坑一:bleed 曾经是空转的。 老版膨胀的是 alpha 通道, 而底图 alpha 全是 255(画布不透明)——MaxFilter 对恒定值就是恒等。 所以"渗化"一次都没生效,写着 bleed=1.8 实际等于 0。 归因实测:色数 / 噪点 / 强边三项和"无任何后期"完全相同。 现在改成在 RGB 上做。

坑二:纸纹曾经是"电视雪花"。 老版是 px[x,y] += randint(-s, s) —— 逐像素白噪声。量出来它把噪点幅度从 1.26 冲到 3.42、色数 377 → 884, 而"水彩感"一点没涨:白噪声做出来的是雪花,不是纸。 视觉复核独立给出了同一个判断——把老版那一段评为 "最像电视雪花噪点:布满细密颗粒,边缘仍清楚,色块发灰、通透感弱", 新版评为 "最像水彩画:纸张纹理与朦胧斑驳明显,边缘柔和,色块半透明、相互晕染"。 指标和眼睛对上了,才确认改的是真问题。

真正的纸有两点白噪声没有的东西:① 纤维(一个方向长、一个方向短) ② 尺度(纸纹比像素大得多,放大要能看见"纹路"不是"沙子")。 现在用"低分辨率噪声放大 6 倍 + 很轻的细颗粒",并且三通道同加同一个亮度值, 所以色相和饱和度完全不动。

[!warning] 渗化不能整幅混,只能限定在轮廓附近 试错两次,都记下来: ① 逐像素按局部差异混 → 色数 377 → 2277(平涂区被轻微混合,干净色块碎成渐变) ② Image.blend(im, blurred, 0.30) 整幅恒定 alpha → 色数 377 → 1786 (恒定 alpha 仍然是"每个像素都变",平涂区也变了)

正确做法是先做一张 mask:只在有轮廓的地方允许混,平涂区 mask=0 → 像素值原样保留。"平"和"水彩"不冲突,冲突的是"整幅一起动"。

4.3 阴影#

用途 颜色 做法
地面接触投影 #8C9EAE @ 26% 压扁的椭圆,模糊 2px
跳跃时的投影 同上 @ 12% 同时缩小 40%(离地越高越小越淡)
角色自投影 #AEBAC6 @ 30% 一块,硬边但很浅
主体锚 深石青 #4E5A66 @ 16% 兔子正后方一圈极淡的径向暗角,见下
投影不是装饰,是**可读性**

兔子跳起来的时候,没有地面投影就看不出脚有没有离地。

主体锚是实测加上的:整套色板都是浅色、又禁止加黑描边, 结果兔子(米白)压在天空/远山(也是浅色)上时会糊掉。 两轮视觉复核都指出"兔子和背景融色"。 解决办法不是加黑线(违反规范),而是在兔子正后方压一圈 16% 的暗角—— 它把主体"顶"出来,又在色板之内。


五、序列帧动画规范#

5.1 为什么是"生成器逐帧画",而不是切图或生图#

做法 问题
生图序列帧 帧间形变(形状每帧都在变,像融化)、帧间漂移、帧数不够
从整图上切零件 参考图那只头身连成一体,切不开;硬切会露出一条直边
生成器逐帧画 ✅ 帧与帧之间只有显式参数在变,不存在形变和漂移;改一次腿长,全部 42 帧一起变

工具:tools/art/notes_art.py(PIL,无外部依赖)。它同时出序列帧、场景物件、小场景预览三样。

python3 tools/art/notes_art.py --only all      # 全出
python3 tools/art/notes_art.py --only rabbit    # 只出 5 组序列帧
python3 tools/art/notes_art.py --only check     # 只跑脚底校验(触地帧必须钉在地面线)
参数 出什么
rabbit 5 组序列帧 → docs/野花笔记/_art/anim/*.webp + manifest.json
props 17 个场景物件 → docs/野花笔记/_art/props/*.webp
scene 小场景拼接预览 → art/scene-preview.png
sheet 序列帧总览 + 物件总览 → art/frames-contact.png、art/props-sheet.png
check 逐组报触地帧最大偏移与最高离地

5.2 五组动画#

组 帧数 fps 什么时候播
stand 8 8 待机(呼吸 + 耳朵摆动 + 眨眼)
hop 10 12 小跳(障碍 ≤ 30px)
leap 12 11 大跳(障碍 30~72px / 跨缺口)
land 6 14 落地缓冲(重量感的全部来源)
look 6 7 跑到岔路口/观察点时停下的张望
组 总帧 触地帧 腾空帧 foot_lift
stand 8 8 0 0
hop 10 3 7 身体抬升 × 0.92
leap 12 2 10 身体抬升 × 0.80
land 6 6 0 0
look 6 6 0 0

画布 128×128,地面线 GY = 112,兔子中心 CX = 64。

实测的脚底离地量(正数=离地,地面线 y=112):

组 逐帧离地量 峰值 触地帧最大偏移
stand 0,0,0,0,0,0,0,0 0 0px
hop −2, 3, 15, 28, 38, 38, 30, 15, 1, −2 38px 2px
leap 0, 6, 18, 34, 49, 62, 68, 64, 51, 34, 14, 0 68px 0px
land 1, −2, −1, 1, 2, 1 2 2px
look 0,0,0,0,0,0 0 0px
脚底对齐这一段踩了七轮,两个坑值得记下来

① foot_lift 对脚底不是恒等映射,所以不能"量出偏移就直接补"。 髋、膝、脚三处耦合,膝盖还有个夹取值会跟着变 —— 实测给 foot_lift 加 −3, 脚只动了 1px。调这个参数调了 7 轮都没调对,因为偏移根本不是它造成的。

② 更关键的一条:hipy 漏了 foot_lift。 原来写的是 hipy = GY - 30 * squash + body_y,少了 foot_lift: 身体升上去、髋部留在原地 → 腿被拉长去够地面 → 脚永远踩在地上,腾空帧也踩着。 这一个漏项同时造成了三个假象:hop 只离地 8px、leap 完全不离地、land 脚陷地下 7px。 修掉之后 hop 立刻变成 40px。 "腿改了、髋没改"是这类骨架代码最典型的漏改,靠肉眼看帧根本发现不了。

③ 最后靠"扫描 + 自动标定"收口。 calibrate() 会扫描补偿量, 取"触地帧最大偏移最小"的那个,不依赖任何关于"谁在动"的假设。 迭代逼近和手推公式都试过,前者会过冲(+7 → +10),后者符号还容易搞反。

[!danger] 关于影子,这里有两个都踩过的坑 ① 腾空时影子不许消失。 原来 alpha = 74 × shadow,而 shadow 在最高点掉到 0.12, 算出来 alpha ≈ 9、半径又只有 1.8px —— 影子的 alpha 直接变成 0,整块没了。 表现是"跳起来的时候脚下什么都没有",反而更看不出离地。现在 alpha 有地板值(≥30), 半径也有下限(≥0.5):离地靠"影子变小变淡"表达,不是靠"影子没了"。

② 校验脚本曾经量的是影子,不是脚。 影子画在最下面,所以"最下面一个不透明像素" 永远是影子的底边,不是脚。于是 --probe 一直在量影子,报出来的"漂移 2px" 其实是影子的漂移,跟脚一点关系都没有——脚本在量错的东西,却一直是绿的。 现在 foot_y() 会按颜色先把影子剔除再扫。

合计 42 帧 / 128×128px / 约 462 KB(WebP 无损,5 张精灵表)。

128px 是"够用就好"的尺寸

兔子在游戏里显示约 62px 高,按 128px 出图有 2 倍余量(高清屏够用), 又不至于浪费包体 —— 序列帧体积随尺寸平方缩放,按 300px 出图会白白多 5 倍体积。

5.3 三条硬规矩#

  1. 地面锁定——但它只管"触地帧": 站着、走着、落地缓冲这些帧,脚底必须画在同一条线(画布 y=112)上, 身体怎么动、腿怎么收都不许改这条线。 校验方式是硬的——--probe 逐帧扫出脚底像素的 y,实测最大漂移 2px。 超过 3px 就说明某帧的姿态把脚画飞了,必须回去改参数。
「地面锁定」的适用范围踩过一次坑

第一版把它用在了所有帧上——包括腾空帧。结果视觉复核的结论是 "双脚仍像着地,跳跃感不足":兔子身体明明抬到 66px 高了, 脚却还钉在地面上,看起来像被拉长而不是跳起来。

正确做法是给绘制函数加一个 foot_lift 参数: 触地帧传 0(脚钉在地面线),腾空帧传正数(脚跟着身体抬起来)。 实测小跳取 抬升 × 0.62、大跳取 × 0.80(大跳抬得更多、更快, 最高点的剪影是"腿悬在身下")。

一句话:踩在地上的时候不许漂,飞在天上的时候必须离地。

还有一个连带的坑:--probe 自己一开始是错的。 它对所有帧都要求"脚底 = 地面线",所以腾空帧一改对就报"漂移 51px"—— 把正确的行为当成了错误。现在它按组分开报(触地帧报漂移、腾空帧报离地量)。 校验脚本写错比不写更危险:它会让你把对的改回错的。

触地帧的 foot_lift 还不许为负:leap 的最后一帧身体下压(抬升 −3px), 负的 foot_lift 会让脚沉到地面线以下,实测漂移 4px(穿模)。加 max(0, ...) 修掉。

  1. 跳的幅度要能一眼看出区别(实测踩过):第一版的 hop 和 leap 视觉复核的结论是"第 2、3 行看不出区别"。修法是让两档在姿态上分开,不是只差高度:

    • 小跳 = 身体前倾 + 腿往肚子底下收
    • 大跳 = 后腿蹬直往后伸 + 鼻子先朝上再朝下(一次明显的仰头到俯冲)
  2. 前腿和后腿不许黏在一起:近侧腿粗一档、往前错开,远侧腿细一档、 往后下方错开。第一版两条腿几乎并线,复核的结论是"腿部看不清、粘连"。

5.4 帧图集#

序列帧 · 五行依次为待机 / 小跳 / 大跳 / 落地 / 张望(每行从左到右播放)
序列帧 · 五行依次为待机 / 小跳 / 大跳 / 落地 / 张望(每行从左到右播放)
小跳和大跳**必须在姿态上分开**,不能只差高度

视觉复核前后看过四轮,前两轮的结论都是"第 2、3 行看不出区别"。最后靠三件事分开: ① 小跳身体前倾、腿往肚子下收;大跳后腿蹬直往后伸; ② 大跳的鼻子先朝上再朝下(一次明显的仰头到俯冲),小跳没有; ③ 大跳中间有 2~3 帧几乎停在最高点(滞空长)。 只把高度调高是不够的——高度差在连续播放时几乎看不出来。


六、五段风景带配色(现行,与 notes.js 的 BANDS 逐值一致)#

每带的天空 / 远山 / 中景 / 地面 / 土壤都从第二节的色板里取,不许新调色。

带 天空 远山(亮而糊) 中景树剪影 地面(舞台) 土壤剖面
苔原草坡 #A8AEBB #8E97A8 #4F5764 #3C424B #474D59
石楠台地 #A6ACBA #8C95A7 #555F70 #383E48 #474D59
溪谷林地 #A4AAB4 #808A9B #484F5A #343941 #3C424B
砾石滩 #A8AABA #7B8093 #575D6D #3A3E4A #464C5A
高地野花田 #A7ADBD #8892A1 #555F70 #3A404C #474D59
这张表换过两次,两次都是**判据用错了**

第一版(表里原来那些 #E8E3D5 / #A88763 / #7E9B93)是从小农场那套清新风 直接搬过来的 —— 暖米白天空 + 土黄土壤 + 小清新绿,和灰蓝水彩毫无关系。 它甚至不在第二节的色板里,那句"不许新调色"当时就是一句空话。

第二版我按"色相要区分"改了,但只看色板值、没看屏幕值: 按色板算"天空与远山差 0.19",渲染出来只有 0.043 —— 因为天空有渐变、远山被混色、地面被提亮。整整一轮调色都没生效, 而我还以为"改了颜色数值却完全不动"是缓存问题。

现在这张表是按渲染后的实测值反推出来的,并且每一带都过了 check_tone.py --scene(见 8.5.1)。

[!important] 为什么五带的色号看起来"差不多" 这是同一个世界的一条跑道,不是五个关卡。五带的明度、饱和、色度都压在同一档 (实测明度 0.6030.626、饱和 14.617.5%、色度 19~22), 靠色相的小偏移(砾石滩略偏紫灰、溪谷偏冷)和中景剪影的疏密来区分。

不要为了"换带要明显"去拉大色差。拉大之后五带会变成五个游戏, 而且必然有一带因为掉出色度区间而不达标(砾石滩第一版就是这样: 饱和 8.2%、色度 7,其余四带 1618% / 1722,一眼就是"这关没做完")。

[!tip] 换带的时候只动环境,不动角色 过渡靠天空带整体渐变(6 秒),兔子和花的颜色不变。 "换了个地方"的感觉靠环境给,不靠给角色换皮 —— 省素材,也更像真的走到了另一个地方。

同理,纵向分四层,相邻层明度差要 >0.05: 天空 0.67 / 远山 0.55 / 中景树 0.47 / 地面 0.59 / 土壤 0.36。 远山必须比中景树亮(空气透视)—— 这一条我两个方向都错过: 先是画得比树还暗(深度反了),改完又和树撞在一起(0.43 vs 0.47,分不开)。

七、和「小农场」的关系(别混用)#

维度 小农场 · 日本清新风 野花笔记(这份)
上色 水彩淡彩,有晕染浓淡 平涂,明度差 ≤6%
线条 蓝灰 #7C8B99,2~3px 同色系但要标基准画布;兔子轮廓压深到 #5C6874
主色 薄荷绿 #A8D8B9 / 奶油白 石青 / 米白 / 苔绿,更冷更哑
情绪 通透、治愈、想待着 安静、克制、像一本植物图谱
亮色 樱粉、淡橙、暖金 几乎不用,暖色占比 ≤3%
动画 木偶(代码驱动骨骼) 序列帧(生成器逐帧画)

两套不许混在同一个画面里。


八、素材预览(现行素材全部由 gpt-image-2 生图产出)#

这一节**整节换过**:旧版素材是代码画的几何图形,已全部作废

旧版的做法是 tools/art/notes_art.py 用 PIL 画椭圆和矩形拼出兔子和植物。 那是根本性的路线错误 —— 用户看到成品后的原话是 「你是在逗我吗 你生图使用的是生图工具吗 风格完全不对 完全不是一样的东西」。

现在全部改成生图(gpt-image-2,图生图),做法与来源见 参考图元素拆离 与 素材总表。

8.1 现行画风基准:参考图拆出来的元素#

从参考图拆离出的 12 个元素
从参考图拆离出的 12 个元素

这 12 个元素是新素材的风格来源 —— 所有动物、植物、场景元素都拿它当参考图喂进生图模型, 所以它们看起来是一套。

其中 7 号是一只兔子,它定义了这只兔子该长什么样: 正面坐姿、头占整体高度约 45%(婴儿比例)、耳朵占身高约 1/3、内耳浅棕带斑点、 毛用短线和点表现且轮廓被毛尖打断、胸前有棕色短线和小点、夸张的长胡须。

参考图里的兔子
参考图里的兔子

8.2 兔子(按参考图重做)#

左=参考图拆出的兔子,中=新做侧视(游戏用),右=新做正面
左=参考图拆出的兔子,中=新做侧视(游戏用),右=新做正面
我按自己想的"可爱"做的 参考图给的做法
头身比 头 ≈ 身的 1/3 头占整体高度 45%
毛的质感 平滑连续的轮廓线 轮廓被一簇簇毛尖打断,内部是短线和点
眼睛 一个黑点 带细小浅色高光的小圆眼
细节 无 胸口的棕色短线与小点、夸张长胡须
**"毛茸茸"不是把轮廓线画软,是让轮廓线被打断、内部填短线和点**

这是一条画法差异,不是形容词差异。按形容词做出来的东西不像参考图, 按画法做出来的像。

兔子:从"透明的骨架"到最终定稿#
左起:旧原稿(认可)/现行 run / idle / jump(统一到同高,公平对比)
左起:旧原稿(认可)/现行 run / idle / jump(统一到同高,公平对比)

这一节记三件事:"透明"的真根因、一次我自己做出来的假对比、最终定稿的参数。

一、"透明"的真根因:抠底把身体挖空了#

anim_sheets.py 的 cut() 原本是全图一刀切阈值(拿角落一个像素当纸底, |a−bg|.sum() < 76 判为背景)。而实测:

到纸底中位的曼哈顿距离
纯纸底(四边) p50 1~4,max 12~18
兔子毛的身体(画面中心) p50 2~10

兔子的毛和纸底是同一个颜色,整个身体被判成背景挖掉,只剩骨架。 每帧包围盒内填充率只有 8.0~11.4%(正常 40~70%)。调容差救不了: 两个分布重叠,不存在能分开它们的阈值。

修法:cut_background() 先按紧容差(>26)找轮廓 → 膨胀 3 像素把毛尖缺口糊上 → 再从封闭轮廓外面 flood fill。画风要求轮廓"被毛尖打断",那些缺口就是 fill 的入口。

二、一次我自己做出来的假对比(比 bug 更值得记)#

用户说「这质感一样吗???现在的毛茸茸的可爱的感觉都没了」。 我去做了一个"生图原始 vs 成品"的对比图,结论是成品糊了、有锯齿、有白边 —— 然后照着这个结论去改了三轮。

那个对比图本身是假的:

原图高度 我展示时 等效缩放
生图原始 400 px 显示 430 px 1.08×,接近 1:1 → 锐
成品 218 px 显示 430 px 1.97×,放大一倍 → 必然糊

我拿一张放大两倍的去和一张原尺寸的比,然后怪自己的处理糊。 改成"两边都缩到同样的最终像素高度、再同样放大"之后,结论直接反转: 成品的清晰度和生图原始是同一水平,甚至因为暖调显得层次更足。

左起:旧原稿/生图原始缩到同尺寸/成品 —— 三者统一高度、统一放大3倍,分辨率公平
左起:旧原稿/生图原始缩到同尺寸/成品 —— 三者统一高度、统一放大3倍,分辨率公平
教训:**对比图必须先把两边的尺度统一,再看差异。**

否则你看到的"差异"全是缩放比造出来的。 这和之前"量兔子要量内部像素""量接缝要量画布不要量截图"是同一条: 先确认你量的/看的对象,是不是你要评价的那个东西。

实测佐证:成品相对生图原始的高频能量保留 91%(127 vs 139)—— 笔触本来就没丢,那三轮改动改的是不存在的问题。

(那个"厚白边"倒是真的,见下。)

三、真被修掉的三处#
# 问题 原因 修法
① 一圈米白厚边 cut_background 输出的透明像素RGB 仍是米白纸底;resize 时 LANCZOS 把米白混进边缘 resize_rgba():预乘 alpha 再缩放,反预乘回来
② 膨胀进了输出 alpha = ~seen 把封缺口用的 3px 膨胀也算成前景,轮廓外胖了 3px、毛尖被糊 fg = far | body:膨胀只当填充的墙,不进输出
③ 边缘锯齿 缩放后一刀二值化 alpha 改为保留抗锯齿(中位 alpha 244~252,是干净的 1px 边,不是雾)

另外把归一化和体型收窄合成了一次重采样(原来分两步,等于对同一张图做两次 LANCZOS), WEBP 质量 92 → 97。

四、最终定稿的参数#
项 值 说明
毛色 暖白 (250,249,238) 用户的定稿色,R−B −12 偏暖
体型 不收窄(SLIM_X = 1.0) 圆润
占屏高 15~16% sc = 0.70
填充率 38~52% 实心
半透明 中位 alpha 244~252 干净的 1px 抗锯齿
「可爱」= 圆 + 暖,这两条我一度都反着做了

用户先说「蓝色基调 瘦瘦的」,我做成冷蓝灰 + 横向挤 0.78, 结果反馈是「可爱的感觉没了」。问过之后确认要回暖白、回圆润。

可爱感很大一部分来自「圆 + 暖」:冷灰产生距离感、瘦长削弱圆润, 两条一起上,"可爱"就没了。 用户提"改成某个属性"时,既要把方向问清,也要把"度"问清 —— 我按字面把两条都做到了极端,反而把原稿最好的东西做掉了。

现在 SLIM_X 这个常数留在代码里:以后要调体型,动这一个数就行。

8.3 动物动画(6 种,可逐帧播)#

动画 帧数 fps
兔子奔跑 8 12
兔子跳跃 6 10
麻雀 6 10
萤火虫 6 7
甲虫 6 9
蝴蝶 6 13
兔子奔跑 · 8 帧循环
帧 1 / 8
兔子跳跃 · 蹲身 → 蹬地 → 上升 → 收腿 → 伸腿 → 落地
帧 1 / 6
序列帧的两条硬要求(都踩过)

① 必须透明底。 旧版 spritesheet.py 里 normalise() 写死 Image.new("RGB", ...), 每帧被刷上生图的米白色 —— 在浅色文档页上看不出来,叠到真实场景就是一块块米白方块。 ② 不能用等分格子切件。 生图的姿态会超出格子,等分切会把角色切断, 而且等分切看不出这件事(总能切出对的数量,每张大小一样,只是内容缺一块)。 改用二维连通域切(tools/art/pose_sheet.py)。

8.4 植物:30 株(5 带 × 6 株)#

五带植物总览 · 每行一条风景带
五带植物总览 · 每行一条风景带

每条带 6 种形态,见 素材总表 第二节。其中 5 株有摇摆动画:

苔原草坡 · 植株摇摆(8 帧)
帧 1 / 8
摇摆**不是生图**,是同一张图的**连续变形**

让生图"再摆一个姿势",出来的就是另一株植物了。 变形做法:绕根部旋转 + 按行水平位移 + 相位滞后。 方向必须"根稳梢摆" —— 第一版把 bend 写反了(两次取反正好抵消), 结果底部摆得最大、顶端不动。验法是量每 10% 高度带的水平重心位移: 底 10% 必须是 0.00 px。

8.5 场景元素(36 件)与拼接场景#

场景元素总览 · 石头/树木/灌木/草丛/枯木/远山,每行 6 个变体
场景元素总览 · 石头/树木/灌木/草丛/枯木/远山,每行 6 个变体
溪谷林地 · 用元素拼出来的竖屏场景
溪谷林地 · 用元素拼出来的竖屏场景

场景是拼出来的,不是整张生成的。 整图生成有两个问题: ① 每次出来的不一样,改一处要重生成整张;② 背景前景焊死,做不了视差滚动。

8.6 可玩的测试场景#

野花笔记 · 走路测试场景(按住左右半屏控制小兔子)

页面里已经落地的五条(都踩过,见 11.5):

项 现状 说明
地面 地平线 y=856、可走线 y=884,地面带只占画面 11% 侧视游戏的路面是底部一条窄带,不是一大片地(原来 41% 是俯视的地)
尺度 树 300470 / 灌木 120215 / 草 150260 / 石·枯木 50120 全部放大过一轮,原来花草比兔子还小
跳跃点 小石头 x=950(小跳)、大木头 x=1850(大跳) 不用玩家按键,跑到就起跳;每处带 done 标记,否则落地后还在触发范围内会连跳两次
调色 烘焙进图层图片(grade() + grain()) 不要用 CSS filter,理由见 11.5
缩放 吸附到整数比 540x960 / 486x864 / 450x800 / 432x768 / 405x720 / 360x640 自动算出的任意小数(如 484x861)会出问题,见 11.5

跳跃是「触发器 + 抛物线」,不是「播一段跳跃动画」。 动画只管姿态、位移由引擎算, 所以腾空时锁同一帧、跳多远都成立。姿态按进度 t 取: t<0.18 蹲 → t<0.30 蹬 → t∈[0.30,0.86) 恒为腾空帧 → t≥0.86 落地。

九、十二条可执行的验收标准#

为什么从八条加到十二条,以及为什么**分了两个口径**

旧版八条全部通过,但用户看到实物图的原话是「美术风格完全不对啊」。 原因很直接:旧八条里一条都没量"明暗关系" —— 它只量了"线够不够细""有没有纯黑" "有没有高饱和",而翻车翻在"整幅亮了一档、暗部为零"。 标准漏了维度,全过也没有意义。

新加的 1~3 条就是补这个维度。补完之后又发现第二个问题: 单件素材和整幅场景不能共用一个口径。

9.1 两个口径(这一步不能省)#

口径 适用 为什么不一样
场景口径 整幅画面(scene-preview.png、shot-*.png、ref-decorative-2x.png) 画面被背景填满,暗部占比是有意义的整体指标
单件口径 带透明背景的素材(props/*.webp、flowers/*.webp、anim/*.webp) 只用不透明像素统计。合成到纸色底再算整幅,透明区会变成亮像素把暗部稀释掉
混用口径会得出完全错误的结论(我踩了)

按场景口径量 bush-0.webp,结果是「平均明度 0.561、暗部 0.0%」—— 看着像"灌木的轮廓没画出来"。按单件口径重量的真值是 平均明度 0.482、暗部 10.8%,轮廓完全正常。 差的那 10.8% 全是透明区被合成成纸色造成的。 一个口径错误可以凭空造出一个不存在的问题。

9.2 判据(三个口径,tools/art/check_tone.py)#

判据改过两轮,两轮都是**判据错了**,不是画错了

第一轮:拿"全图暗部 ≥5%"当判据 → 全部场景不达标。 查下来它量的是构图密度:参考图是一条花纹边框,元素填满 67.5% 画幅; 我的场景只有 18.4% 是元素,其余是天空 —— 全图暗部永远到不了 5%。 而按元素口径看,我的画法比参考图还暗一档(13.8% vs 10.5%)。 拿一个量构图的指标去判画法,会得出"画法不合格"这个完全错误的结论。

第二轮:阈值定 8% → 参考图自己只有 7.8%,基准图被判不合格。 阈值必须低于基准图的实测值,否则就是自相矛盾。改成 6%。

场景口径(整幅画面,--scene)

判据 阈值 基准图实测 我的场景实测(五带)
整体平均明度 0.55 ~ 0.72 0.601 0.571 ~ 0.595
饱和(P90) ≤ 22% 15.2% 14.4 ~ 17.8%
色度(P50) ≥ 13 12 15 ~ 22
元素内暗部占比 ≥ 6% 7.8% 22.0 ~ 24.2%
元素内平均明度 ≤ 0.72 0.642 0.601 ~ 0.618
「色度 ≥ 13」是这一轮**新加的下限判据**,也是这一节最重要的一条

现场回放:用户说「灰度过重、对比不足」,而我跑完 check_tone 得到的是一排 ✓。 为什么?因为当时只有饱和度的上限(10%),没有下限 —— 而上限拦的是"太艳",根本拦不住"太灰"。 我的场景当时 P90 只有 3.9%(基准 15.2%),色度只有 6(基准 12), 一路绿灯。

判据有效性的验法(这条以后照做):拿三个已知样本跑一遍, 旧版必须被拦下、新版必须通过、基准在中间 ——

样本 饱和 P90 色度 P50 色度≥13 的判定
旧版(用户说太灰那版) 3.9% 6 ✗ 拦下 ✓ 正确
基准图 15.2% 12 ✗(基准不参与判定,只比对)
新版(现行) 14.4~17.8% 15~22 ✓ 通过

三个样本一眼可分,判据才算立住。只能"全过"的判据等于没有判据。

[!warning] 反过来也要防:主观评价不构成判据,除非它对基准图有区分度 这一轮修完之后,我把图送去视觉复核,回答仍然是「整体明显发灰发闷」。 看起来像是"改了个寂寞",但同一套提问拿去问基准图,回答是「这张图整体偏灰」—— 参考图自己也是灰的。

所以"发灰"这个评价对基准图同样成立,它不区分"做对了"和"做过灰了"。 这种评价不能当判据用,只能用来看"有没有硬伤"(错位、糊成一团、元素丢失)。

能不能当判据,验法是一样的:拿基准图跑一遍。 基准图也会被判"不合格"的判据,一律不能用 —— 无论它是脚本还是人。 (这和"阈值必须低于基准值"是同一条规矩的两个面: 一个是数值面,一个是主观面。)

[!note] 为什么是「元素内」而不是「全图」 elem_mask() 用局部对比(与高斯模糊的差 >0.05)把元素圈出来 —— 平滑的天空和地面底色不会被算进去。这样量出来的才是画法, 不受"这一关天空留了多少"影响。

单件口径(带透明背景的素材,--sprite)

判据 阈值 说明
最低明度 ≤ 0.45 = 描边真的画出来了
平均明度 0.30 ~ 0.85 草偏暗(0.39)、石头偏亮(0.68)都正常
单件**不拿"暗部占比"当判据**(这条也改过)

「暗部占比」量的是【线条面积 / 总填充面积】,偏向"线条多"的素材:

素材 暗部占比 实际情况
grass-0 33.1% 全是竖线
tree-pine-0 10.1% 密实剪影
tree-broad-0 2.9% 亮灰叶团
rock-0 2.2% 描边明明画出来了

石头只有 2.2%,但它的描边完全正常。用占比判它,会得出"石头没有描边" 这个错误结论,然后去改一个根本没问题的函数。

面板口径(UI 占大半画幅的截图,--panel)

线索卡 / 小游戏 / 手账这三张截图,UI 面板占了画幅大半。 面板本来就该比画面亮(可读性优先),所以只判「整体明度 ≤ 0.78 + 饱和 ≤ 22% + 色度 ≥ 13」, 不判元素暗部 —— 否则等于要求"对话框也要够暗"。

拼接图口径(--composite,只给 scene-preview.png 这一张静态拼接图)

拼接图**不能按场景口径判** —— 这是同一个坑的第二次

「元素内暗部」靠 elem_mask() 圈元素,判据是局部对比(与高斯模糊差 >0.05)。 静态拼接图(先铺底、再叠物件)里树和灌木是一整块平涂,模糊值和它自己一样, 根本圈不进来;能圈进来的只有细描边。 于是这一条量的变成【描边面积 / 总填充面积】,不是画法。

图 元素内暗部 说明
运行时截图 band-1~5 20.7 ~ 22.5% ✓
静态拼接 scene-preview 1.3% ✗ 同一套色板、同一套画法

差 16 倍,差的是图的做法,不是画法。 单件素材当时踩过同一个坑(石头 2.2% 被判"没描边"),那时改判"最低明度"; 拼接图没有 alpha,改不了最低明度,所以直接不判这一条, 只判它对得上的三条:整体明度、饱和(防艳)、色度(防灰)。

它真正该验的是"色调统不统一、前后景分不分得开",那两条看第八节的图,不靠脚本。

基准比对(--bench <参考图>)

参考图是基准,不是被判定的对象。拿"给场景定的规则"去判基准图, 会得出"基准图自己不合格"(第一版就出了这个结果)。 基准只打印实测值供比对,不计入通过数。

9.3 脚本#

# 单件素材
python3 tools/art/check_tone.py --sprite "docs/野花笔记/_art/props/*.webp"
python3 tools/art/check_tone.py --sprite "docs/野花笔记/_art/anim/*.webp"
# 场景(运行时截图,判全部五条)
python3 tools/art/check_tone.py --scene docs/野花笔记/art/band-*.png
# 静态拼接图(不判元素内暗部,理由见上)
python3 tools/art/check_tone.py --composite docs/野花笔记/art/scene-preview.png
# 基准比对
python3 tools/art/check_tone.py --bench docs/野花笔记/art/ref-decorative-2x.png docs/野花笔记/art/band-1.png
# 面板(线索卡 / 小游戏 / 手账)
python3 tools/art/check_tone.py --panel docs/野花笔记/art/shot-branch.png docs/野花笔记/art/shot-mini*.png
# 饱和度(第 9、10 条)
python3 tools/art/check_sat.py docs/野花笔记/art/*.png docs/野花笔记/_art/props/*.webp
六个坑,全是真踩过的
  1. 口径混用:按场景口径量 bush-0.webp 得出"暗部 0.0%、轮廓没画出来", 按单件口径真值是 10.8%、完全正常。一个口径错误可以凭空造出问题。
  2. 判据量错了对象:全图暗部量的是构图,不是画法。见上面第一轮。
  3. 阈值高于基准值:定 8%,基准图 7.8% —— 自相矛盾。
  4. 暗部占比偏向线条多的素材:石头 2.2% 被判"没描边"。
  5. 过期截图:shot-band2.png 是 12:15 的旧文件(生成它的逻辑早删了), 带着旧色板混进验收,伪造出两张"不达标"。验收前先确认截图是本轮生成的。
  6. UI 面板按场景判:线索卡截图被判"太亮",而面板本来就该亮。
  7. 判据的方向搞反了:饱和度上限拦的是"太艳",拦不住"太灰"。 我拿一个上限去对付"灰度过重",于是用户看得见的问题在验收里是隐形的。 "太灰"和"太艳"是两个方向,各要一条判据。
  8. 阈值比基准还严:色板色号饱和定 ≤20%,而 HSV 饱和在暗色上会自动虚高 (#292D35 明度 0.18 → HSV 饱和 24.5%,色度却只有 12、和基准一致)。 差点为了迁就一条错判据,把本来就对的暗色改坏。对暗色用色度,不用 HSV 饱和。
  9. 色板值 ≠ 屏幕值:我按色板算"天空与远山差 0.19",实测屏幕上只有 0.043 —— 因为天空有渐变、远山被混色、地面被提亮。 同时树带画在 y 47%~69%(SKY_H+4 起、高 160),我却一直量 43%~51% —— 那段全是天空。量之前先确认元素到底在哪个区段。

十、演示里的实测截图#

演示 · 开局(自动跑动中,兔子在跳)
演示 · 开局(自动跑动中,兔子在跳)
演示 · 换到第 2 带(石楠台地)
演示 · 换到第 2 带(石楠台地)
演示 · 岔路口:线索卡翻开,等玩家选一个出口
演示 · 岔路口:线索卡翻开,等玩家选一个出口
演示 · 跳铃铛小游戏:三个不同高度的铃铛,按住蓄力决定跳多高
演示 · 跳铃铛小游戏:三个不同高度的铃铛,按住蓄力决定跳多高
这三张是**渲染实现**,不是美术稿

演示里的地形、花、背景全是 canvas 代码画的(tools/art/scene/notes.js), 只有兔子是序列帧(tools/art/notes_art.py 生成的那 5 组)。 目的是先把构图、比例、节奏、配色验掉;正式素材仍然按第五节的规范做。 配色与线条规则是同一份(都取自第二节),所以这三张可以用来验第八节那八条。


十一、判据的失败记录:同一类错误在生图上连摔四次#

这一节记的是"判据"本身的教训,不是某一版素材的问题

第九节那十二条判据是对的。这一节记的是"我拿着对的判据,还是把同一件事做错四次"。

11.1 事件经过#

用生图做五带背景,一共做了三轮,失败方向正好相反:

轮次 平均明度 色度 元素内暗部 饱和 P90 判定
第一轮 0.738 ~ 0.838 4 ~ 12 — — 太亮
第二轮 0.374 ~ 0.485 14 ~ 26 27 ~ 48% 17.6 ~ 21.9% 太暗
第三轮(联合标定) 0.590 ~ 0.614 13 ~ 17 6.9 ~ 21.4% 13.0 ~ 14.8% ✓ 全部达标
判据 0.55~0.72 ≥13 ≥6% ≤22%

每一次都是"修一个方向,冲到相反方向":

我修的问题 加的手段 结果
太亮(0.84) 加"约 40% 像素暗于 30% 明度"的硬约束 → 太暗(0.38)
太暗(0.38) 用 gamma 提亮到基准 → 暗部被提没了(47.5% → 3.8%)
太灰(色度 3) 加"必须有可辨认的蓝绿倾向" → 太艳(饱和 32%,上限 22%)
太艳(饱和 32%) 向亮度混合降饱和 → 才能同时满足四条

11.2 这四次摔的是同一个坑#

4.1.6 第 1 条白纸黑字写着:

「"太灰"和"太艳"是两个方向,各要一条判据」

我嘴上引用它,手上每一次都只推一个方向。而且不止"灰/艳"这一对 —— "亮/暗"也是两个方向、"暗部多/暗部少"也是两个方向,全都犯了同一个错。

真正的教训不是"下次小心",是一条操作方法:

加任何一条约束时,必须同时问一句「它的反方向会不会冲过头」, 并把反方向的界一起写上。

只写一边(无论是写进提示词,还是写进调参循环),结果必然冲到另一边。

11.3 附带发现的三个技术点#

① 提亮会吃掉暗部,所以提亮曲线要分段。 普通 gamma 是整条曲线一起抬,暗部跟着上去,元素内暗部 直接从 47.5% 掉到 3.8%。 改成分段曲线:knee 以下原样不动,只提中高调 —— 暗部保住了,明度也能上去。 溪谷林地靠这一条拿到 暗部 8.9% / 色度 14 / 明度 0.610。

② 降饱和和提亮要一起解,不能分开调。 石楠台地最后用的参数是联合解:k=0.62(向亮度混合的降饱和系数)g=0.52 knee=0.10。 先降饱和再提亮,或反过来,都试不出解。

③ 验收脚本的调用方式也会骗人。 有一次扫描找不到解,查下来是筛选条件用文件名匹配 heath, 而候选文件存成了 /tmp/cand-h4.png —— 条件从来没匹配上。 "无解"和"筛选写错"在输出上长得一模一样,都是空结果。

11.4 一条更早的同类记录(背景之外的)#

第四轮修"灰度过重"时也踩过同一个坑,那次记在 4.1.6:

  • 当时只有「饱和 ≤10%」这一条上限,而上限拦的是"太艳"。 于是场景 P90 只有 3.9%(基准 15.2%)却一路 ✓。 用户看得见的问题,在验收里是隐形的。
所以第九节的判据表里,每一条**都必须有下限或上限成对出现**

只有单边的判据 = 没有判据。 加新判据时,先问反方向在哪。

11.5 追一条"竖向接缝",最后发现它根本不在画里#

场景页上一直有一条贯穿整幅高度的竖向分界线,视觉复核连着三轮都报"有接缝"。 追了很久,最后证明是无头截图通道的伪影,画布内容本身从头到尾都是干净的。

决定性的一步是同一瞬间、两种取法(cam=1858):

取法 中点处的台阶
cv.toDataURL()(实际显示的内容) 0.57 ✓ 干净
Page.captureScreenshot()(CDP 截图) 4.47 ✗ 有台阶
教训:**"我测到的"和"用户看到的"可能不是一个东西**

前面三轮我一直在优化画布,但报出问题的那个数是截图量出来的。 中间一度把已经改对的东西又改坏(为了压这一条,把纸纹颗粒从 0.010 加到 0.018)。 任何一个量出来的异常,先确认量的对象是不是用户真正看到的那个。

追的过程中排除掉的(都留下了有价值的结论):

怀疑对象 结论
平铺回绕接缝 已修,且确实该修。paste_wrap() 环绕粘贴 + 改用 createPattern 平铺(手动画三份会在交界处出问题)。四层里 l1 的环绕差从 12.42 降到可忽略
drawImage 偏移没取整 无关。取整之后台阶一点没动,说明不是子像素相位
CSS filter 加在 canvas 上 无关,但顺手改掉了。调色改烘焙进图层(grade()),页面一个 filter 都不用加
平铺/缩放的尺寸比例 无关,但顺手改掉了。缩放吸附到整数比(任意小数比更差)

顺手捞到的两条:

  1. 无头浏览器里 rAF 被节流,改完参数画布根本不重绘,截到的是旧帧。 我有一轮跑 18 组调色参数,结果18 组数字完全相同 —— 当时差点当成"调色对画面没影响"。 改完必须强制重绘一帧(或者干脆重新加载页面)再截。
  2. 逐层排查要用画布快照,不要用截图。 off.toDataURL() / cv.toDataURL() 拿到的是真实像素,不受合成通道影响。这个手段一次就把范围缩到了"画布之外"。