摘要:本文复盘一次完整的 Agent 交付:一句"建一台 1970 年代复古胶片单反"的需求,由 WorkBuddy 的 Agent 模式驱动 Blender 5.2.2 LTS 无头运行,全程用 bpy 脚本程序化生成 27,094 个顶点的几何与全部材质,零外部模型、零贴图、零 HDR,最终产出 800×800 的 Eevee 棚拍成品图。11 轮迭代中,画面上方一条 18 级的亮度台阶让五个假设接连落空,改用像素剖面与射线投射做量化定位后,才一轮锁定根因。文中给出三个可复用代码机制与一张五类棚拍伪影定位对照表,适用于需要多轮参数迭代的产品静物渲染任务。
文章目录
-
- [一、引言:一句"建一台复古相机",Agent 要自己回答四个问题](#一、引言:一句"建一台复古相机",Agent 要自己回答四个问题)
- 二、核心概念拆解
-
- [2.1 什么是 WorkBuddy Agent 模式:一条闭环里的"眼睛"](#2.1 什么是 WorkBuddy Agent 模式:一条闭环里的"眼睛")
- [2.2 什么是 Blender 程序化建模:把模型写成代码](#2.2 什么是 Blender 程序化建模:把模型写成代码)
- [2.3 Eevee 摄影棚渲染的核心机制:接触阴影从哪来](#2.3 Eevee 摄影棚渲染的核心机制:接触阴影从哪来)
- 三、环境、约束与交付基线
- [四、几何层:一个不依赖 bpy 的纯几何库](#四、几何层:一个不依赖 bpy 的纯几何库)
- 五、材质层:不用一张贴图做皮革与拉丝金属
- 六、取景与曝光:两个"看起来很合理"的错误
- 七、棚拍伪影定位法:从"改参数猜"到"量化定位"
-
- [7.1 问题现象与我推翻的五个假设](#7.1 问题现象与我推翻的五个假设)
- [7.2 用像素剖面把"看起来不对"变成数字](#7.2 用像素剖面把"看起来不对"变成数字)
- [7.3 修法:把世界对"相机射线"的颜色单独校准](#7.3 修法:把世界对"相机射线"的颜色单独校准)
- [7.4 五类棚拍伪影的定位对照表](#7.4 五类棚拍伪影的定位对照表)
- 八、运行验证:量化体检结果
-
- [8.1 最花心思的一处细节:镜头前组](#8.1 最花心思的一处细节:镜头前组)
- [九、踩坑复盘:11 轮里真正吃掉时间的是什么](#九、踩坑复盘:11 轮里真正吃掉时间的是什么)
- 十、适用边界与风险提示
- 十一、总结
⚠️ 版本说明 :本文基于 WorkBuddy Agent 模式(2026-09)+ Blender 5.2.2 LTS(hash
d13f752e3b9c,构建于 2026-09-15)+ Python 3.13.12,运行环境为 Windows 10 Pro 22H2。bpy 在 4.x → 5.x 之间存在破坏性变更(渲染引擎枚举只剩BLENDER_EEVEE、use_nodes已废弃、ShaderNodeTexMusgrave被移除),跨版本复现请先看第三节的环境清单与第十节的兼容性提示和替代方案。文中的几何算法、材质组合思路与诊断方法均不依赖具体版本,长期适用。
一、引言:一句"建一台复古相机",Agent 要自己回答四个问题
"建一台 1970 年代风格的复古胶片单反相机。机身黑色皮革纹理 + 银色金属顶盖,要有镜头(多层镜片组,最前片玻璃材质)、对焦环、光圈环、快门按钮、过片扳手、热靴、取景器、铭牌区域。所有几何体用 bpy 程序化生成,禁止导入任何外部模型、贴图、HDR。Eevee 渲染 800×800,三分之四视角,主体占画面约 70%,浅灰背景配柔和三点光,主体下方要有自然接触阴影。"
这是这次任务的全部输入。它比"建一台相机"具体得多,但仍然留下的四个必须由执行方自己回答的问题:几何怎么用代码算出来 、材质怎么在不用任何贴图的前提下做出质感 、画面占比与居中怎么保证 、以及渲染出来的伪影怎么定位。
前三个问题是"怎么做",第四个问题是"怎么知道做错了"------而后者才是这次真正消耗时间的地方。整个任务共跑了 11 轮预览 + 2 次正片渲染,前 7 轮几乎全部折在同一个看起来很小的现象上:画面上方有一条横贯的亮度台阶。我先后提出并推翻了五个假设,直到放弃"改参数看结果"、改用像素探针量化,才一轮锁定根因。
最终的交付硬指标如下:
| 指标 | 数值 |
|---|---|
| 顶点 / 面数 | 27,094 / 21,465 |
| 构件数 / 闭合实体数 | 8 / 63 |
| 非水密边 / 退化面 | 0 / 0 |
| 包围盒 | x −77...85 / y −97...26 / z 0...92(mm) |
| 相机 | 距离 498.4 mm / 镜头 58 mm / 方位 35° / 俯仰 31° |
| 主体画面占比 / 投影中心偏移 | 70.2% / −0.00%、−0.03% |
| 背景亮度均匀度 | 231.0 -- 232.8(目标 #E8E8E8,波动 ≤1.8 级) |
| 过曝采样点 | 0 / 17,956(0.00%) |
| 渲染耗时 | 800×800 / 144 采样 / 106.9 秒 |
但这张表里没有任何一项是这次最有价值的东西。真正有价值的是过程中被推翻的那五个假设------它们都属于"听起来完全合理、验证后全错"的类型,而如果没有一套把视觉判断翻译成数字的手段,它们会一路耗到迭代上限。

二、核心概念拆解
2.1 什么是 WorkBuddy Agent 模式:一条闭环里的"眼睛"
WorkBuddy 是一个运行在本地电脑上的 AI 工作台。它提供三种工作模式,用途完全不同:Agent 模式 (你说我做)直接动手执行;Plan 模式 (先想后做)先出方案再等确认;Ask 模式(只说不做)只读不写。
这次全程在 Agent 模式下完成。原因不是"方便",而是建模任务的闭环里必须有一双眼睛 。整条链是:写脚本 → 跑 Blender → 看渲染图 → 判断问题 → 改参数 → 再跑。对话式 AI 只能完成第一环,后面的它看不见也够不着;Agent 模式能读写本地文件、能执行命令行、并且能在每一轮结束时把渲染结果图真正读回来看,这才让"迭代"这个词有意义。
判断环是这条链上唯一无法被脚本自动化的部分------它需要视觉与经验的判断。而这次最大的教训恰好是:当判断环连续多轮给出"还是不对"这种模糊反馈时,就该意识到该把它也变成可量化的东西。 第七节整节都在讲怎么做这件事。
2.2 什么是 Blender 程序化建模:把模型写成代码
Blender 的常规用法是鼠标操作:挤出一个面、拉一条边、拖一个顶点。程序化建模走的是另一条路------用 Python 的 bpy 模块算几何,模型不由鼠标画出来,而由代码生成。
两者最关键的差别不是效率,而是可迭代性与可复现性 。手工建模时,"把镜头往前挪 3 mm"意味着重做一遍;程序化建模时,它意味着把参数区里的一个数字从 -69.5 改成 -66.6,然后重跑。这个项目改了 11 轮,如果每轮都靠手工重做,这件事根本不会发生。
第二个差别是尺寸可信度。手工建模靠目测,"看起来像个相机"就收工;程序化建模把真实 135 单反的尺寸写进参数------机身宽 146 mm、机身厚 42 mm、卡口半径 29 mm、前镜片半径 22 mm------然后所有细节按这个基准派生。这样出来的比例不需要"调好看",它本来就是对的。
第三个差别在实践中更重要:程序化建模可以把几何层从 Blender 里剥离出来。本项目的几何运算全部写在一个不 import bpy 的纯 Python 模块里,于是可以在没有 Blender 的机器上跑水密性与退化面断言。这一点在第四节展开。
2.3 Eevee 摄影棚渲染的核心机制:接触阴影从哪来
第三个概念是摄影棚渲染 。它和"把模型渲染出来"不是一回事------前者要求画面读起来像在摄影棚里拍的产品照:纯色无缝背景 + 柔和三点光 + 主体下方自然的接触阴影。
这里的关键机制是接触阴影(contact shadow) ,也就是物体与台面接触处那圈从暗到亮的过渡。它由两部分叠加而成:主光投下的硬阴影 ,和环境光遮蔽(AO)在接触区被严重衰减造成的软化暗部。缺了前者,主体像悬空;缺了后者的暗部过渡,阴影边缘会硬得像贴纸。
Eevee 的 AO 来自屏幕空间光追,因此有两个必须知道的参数:scene.eevee.ray_tracing_options.screen_trace_thickness 与 scene.eevee.fast_gi_distance。前者决定了"多近的两个面会被判定为互相遮蔽"------而它有一个尺度陷阱 :默认值 0.1 是按"米"这个标度给的,本场景以毫米建模(机身 146 单位长,是 1.46 米若按米算),0.1 的厚度对 AO 而言薄了三个数量级,接触阴影根本长不出来。必须显式放大到 4.0。
配套的第二个认知是:环境光强度与接触阴影是零和关系 。金属材质靠反射环境才能亮,但环境光是无方向的,强度一高就会把主光投下的阴影从里到外填平。所以想让金属够亮又保住接触阴影,正确做法是抬主光能量,而不是抬环境。
#mermaid-svg-84YitzSXRevIPMd5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-84YitzSXRevIPMd5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-84YitzSXRevIPMd5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-84YitzSXRevIPMd5 .error-icon{fill:#552222;}#mermaid-svg-84YitzSXRevIPMd5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-84YitzSXRevIPMd5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-84YitzSXRevIPMd5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-84YitzSXRevIPMd5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-84YitzSXRevIPMd5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-84YitzSXRevIPMd5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-84YitzSXRevIPMd5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-84YitzSXRevIPMd5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-84YitzSXRevIPMd5 .marker.cross{stroke:#333333;}#mermaid-svg-84YitzSXRevIPMd5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-84YitzSXRevIPMd5 p{margin:0;}#mermaid-svg-84YitzSXRevIPMd5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-84YitzSXRevIPMd5 .cluster-label text{fill:#333;}#mermaid-svg-84YitzSXRevIPMd5 .cluster-label span{color:#333;}#mermaid-svg-84YitzSXRevIPMd5 .cluster-label span p{background-color:transparent;}#mermaid-svg-84YitzSXRevIPMd5 .label text,#mermaid-svg-84YitzSXRevIPMd5 span{fill:#333;color:#333;}#mermaid-svg-84YitzSXRevIPMd5 .node rect,#mermaid-svg-84YitzSXRevIPMd5 .node circle,#mermaid-svg-84YitzSXRevIPMd5 .node ellipse,#mermaid-svg-84YitzSXRevIPMd5 .node polygon,#mermaid-svg-84YitzSXRevIPMd5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-84YitzSXRevIPMd5 .rough-node .label text,#mermaid-svg-84YitzSXRevIPMd5 .node .label text,#mermaid-svg-84YitzSXRevIPMd5 .image-shape .label,#mermaid-svg-84YitzSXRevIPMd5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-84YitzSXRevIPMd5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-84YitzSXRevIPMd5 .rough-node .label,#mermaid-svg-84YitzSXRevIPMd5 .node .label,#mermaid-svg-84YitzSXRevIPMd5 .image-shape .label,#mermaid-svg-84YitzSXRevIPMd5 .icon-shape .label{text-align:center;}#mermaid-svg-84YitzSXRevIPMd5 .node.clickable{cursor:pointer;}#mermaid-svg-84YitzSXRevIPMd5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-84YitzSXRevIPMd5 .arrowheadPath{fill:#333333;}#mermaid-svg-84YitzSXRevIPMd5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-84YitzSXRevIPMd5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-84YitzSXRevIPMd5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-84YitzSXRevIPMd5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-84YitzSXRevIPMd5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-84YitzSXRevIPMd5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-84YitzSXRevIPMd5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-84YitzSXRevIPMd5 .cluster text{fill:#333;}#mermaid-svg-84YitzSXRevIPMd5 .cluster span{color:#333;}#mermaid-svg-84YitzSXRevIPMd5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-84YitzSXRevIPMd5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-84YitzSXRevIPMd5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-84YitzSXRevIPMd5 .icon-shape,#mermaid-svg-84YitzSXRevIPMd5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-84YitzSXRevIPMd5 .icon-shape p,#mermaid-svg-84YitzSXRevIPMd5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-84YitzSXRevIPMd5 .icon-shape .label rect,#mermaid-svg-84YitzSXRevIPMd5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-84YitzSXRevIPMd5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-84YitzSXRevIPMd5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-84YitzSXRevIPMd5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 达标
不达标
写 bpy 脚本
无头运行 Blender
渲染出图
Agent 读回渲染图
判断是否达标
输出成品与 blend
提出假设
改参数或改代码
量化探针介入
定位根因
图 1:Agent 建模闭环。注意右下角那条支线------它是本次唯一真正有效的路径。
三、环境、约束与交付基线
先把"什么版本、什么约束"讲清楚,这两项决定了后面所有技巧的适用边界。
| 组件 | 版本 / 配置 | 作用 |
|---|---|---|
| WorkBuddy | Agent 模式(2026-09) | 驱动脚本、读回渲染图、做判断 |
| Blender | 5.2.2 LTS(hash d13f752e3b9c) |
无头运行 bpy 脚本 |
| 渲染引擎 | BLENDER_EEVEE |
5.2 枚举中仅此一项 |
| Python | 3.13.12(Blender 内置同源) | 脚本运行环境 |
| 系统 | Windows 10 Pro 22H2 / i5-9400 / 16GB | 渲染约 107 秒一轮 |
| 色彩管理 | view_transform = Standard |
避免 AgX 洗白浅灰背景 |
任务方给出的硬性约束有五条,每一条都直接约束了设计空间,不是可以打折的建议:
| 约束 | 具体含义 | 带来的设计后果 |
|---|---|---|
| 几何必须程序化 | 禁止导入任何外部模型 | 全部 8 个构件由代码算出,27,094 个顶点 |
| 材质必须程序化 | 禁止任何贴图文件 | 皮革用 Voronoi F1 + Noise,金属用 Bump + 各向异性 |
| 无 HDR 环境 | 世界环境需自己搭 | 金属亮度只能靠程序化环境 + 面光组合 |
| 引擎固定 Eevee | 不可换 Cycles | AO 靠屏幕空间光追,遇到尺度陷阱 |
| 视角与占比固定 | 三分之四视角、主体约 70% | 必须做自动取景,不能靠手调 |
代码组织上做了三层分离,这是整个项目能被快速迭代的前提:
#mermaid-svg-X5euCN8y7bQbr4La{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-X5euCN8y7bQbr4La .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-X5euCN8y7bQbr4La .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-X5euCN8y7bQbr4La .error-icon{fill:#552222;}#mermaid-svg-X5euCN8y7bQbr4La .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-X5euCN8y7bQbr4La .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-X5euCN8y7bQbr4La .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-X5euCN8y7bQbr4La .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-X5euCN8y7bQbr4La .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-X5euCN8y7bQbr4La .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-X5euCN8y7bQbr4La .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-X5euCN8y7bQbr4La .marker{fill:#333333;stroke:#333333;}#mermaid-svg-X5euCN8y7bQbr4La .marker.cross{stroke:#333333;}#mermaid-svg-X5euCN8y7bQbr4La svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-X5euCN8y7bQbr4La p{margin:0;}#mermaid-svg-X5euCN8y7bQbr4La .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-X5euCN8y7bQbr4La .cluster-label text{fill:#333;}#mermaid-svg-X5euCN8y7bQbr4La .cluster-label span{color:#333;}#mermaid-svg-X5euCN8y7bQbr4La .cluster-label span p{background-color:transparent;}#mermaid-svg-X5euCN8y7bQbr4La .label text,#mermaid-svg-X5euCN8y7bQbr4La span{fill:#333;color:#333;}#mermaid-svg-X5euCN8y7bQbr4La .node rect,#mermaid-svg-X5euCN8y7bQbr4La .node circle,#mermaid-svg-X5euCN8y7bQbr4La .node ellipse,#mermaid-svg-X5euCN8y7bQbr4La .node polygon,#mermaid-svg-X5euCN8y7bQbr4La .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-X5euCN8y7bQbr4La .rough-node .label text,#mermaid-svg-X5euCN8y7bQbr4La .node .label text,#mermaid-svg-X5euCN8y7bQbr4La .image-shape .label,#mermaid-svg-X5euCN8y7bQbr4La .icon-shape .label{text-anchor:middle;}#mermaid-svg-X5euCN8y7bQbr4La .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-X5euCN8y7bQbr4La .rough-node .label,#mermaid-svg-X5euCN8y7bQbr4La .node .label,#mermaid-svg-X5euCN8y7bQbr4La .image-shape .label,#mermaid-svg-X5euCN8y7bQbr4La .icon-shape .label{text-align:center;}#mermaid-svg-X5euCN8y7bQbr4La .node.clickable{cursor:pointer;}#mermaid-svg-X5euCN8y7bQbr4La .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-X5euCN8y7bQbr4La .arrowheadPath{fill:#333333;}#mermaid-svg-X5euCN8y7bQbr4La .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-X5euCN8y7bQbr4La .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-X5euCN8y7bQbr4La .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-X5euCN8y7bQbr4La .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-X5euCN8y7bQbr4La .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-X5euCN8y7bQbr4La .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-X5euCN8y7bQbr4La .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-X5euCN8y7bQbr4La .cluster text{fill:#333;}#mermaid-svg-X5euCN8y7bQbr4La .cluster span{color:#333;}#mermaid-svg-X5euCN8y7bQbr4La div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-X5euCN8y7bQbr4La .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-X5euCN8y7bQbr4La rect.text{fill:none;stroke-width:0;}#mermaid-svg-X5euCN8y7bQbr4La .icon-shape,#mermaid-svg-X5euCN8y7bQbr4La .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-X5euCN8y7bQbr4La .icon-shape p,#mermaid-svg-X5euCN8y7bQbr4La .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-X5euCN8y7bQbr4La .icon-shape .label rect,#mermaid-svg-X5euCN8y7bQbr4La .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-X5euCN8y7bQbr4La .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-X5euCN8y7bQbr4La .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-X5euCN8y7bQbr4La :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 场景层 主脚本
材质层 camlib.py
mat_leather
Voronoi F1 加 Noise
mat_brushed
Bump 加 Anisotropic
mat_glass mat_simple
几何层 camlib.py
不依赖 bpy
纯几何原语
rrect loft revolve rounded_box
校验 Part
open_edges degenerate_faces
变换 move rotx cyl_x cyl_y
八个构件构建函数
灯光 世界 摄影棚
自动取景与居中
几何层
材质层
图 2:三层分离架构。几何层与校验层不依赖 bpy,因此可以离线跑断言。
四、几何层:一个不依赖 bpy 的纯几何库
几何层的设计原则只有一条:除了最后的 mesh.from_pydata,代码里不出现任何 bpy 调用。所有原语都只是"返回顶点列表和面索引列表的纯函数",于是它们可以在没有装 Blender 的环境里被 unittest 验证。
机身形态来自一个关键原语:圆角长方体。相机机身是"长方体 + 上下沿圆角"的形态,不是球角------四角有较大的水平圆角,而侧面竖直、只在上下边缘收圆。用一个球面收缩量来控制每一层截面的内缩,就能得到真实的手感。
python
def rounded_box(cx, cy, z0, dz, hw, hd, rh=5.5, rv_top=6.0, rv_base=2.4,
n=72, nz=14):
"""圆角长方体:侧面竖直、上下沿圆角 ------ 正是相机机身的形态。
rh ------ 水平方向(四角)圆角半径
rv_* ------ 垂直方向(上下沿)圆角半径;底面比顶面小,做出"皮套收口"感
收缩量按球面公式 shrink = rv * (1 - sqrt(1 - t^2)),
保证底部是真正的球面过渡,而不是简单的线性倒角。
"""
# ---- 下半:底面圆角段 ----
levels = []
for k in range(nz + 1):
t = k / float(nz)
s = rv_base * (1.0 - math.sqrt(max(0.0, 1.0 - (1.0 - t) ** 2)))
levels.append((z0 + rv_base * t, s))
# ---- 腰部:收缩量为 0,保证侧面保持竖直 ----
levels.append((z0 + dz * 0.5, 0.0))
# ---- 上半:顶面圆角段 ----
for k in range(nz + 1):
t = k / float(nz)
s = rv_top * (1.0 - math.sqrt(max(0.0, 1.0 - (1.0 - t) ** 2)))
levels.append((z0 + dz - rv_top * (1.0 - t), s))
# ---- 逐层生成水平圆角矩形,再交给 loft 连接并封盖 ----
layers = []
for (z, s) in levels:
w = max(hw - rh - s, hw * 0.05) # 钳位:防收缩量吃穿截面
d = max(hd - rh - s, hd * 0.05)
layers.append([(x, y, z) for (x, y) in rrect(w, d, max(rh - s, 0.4), n)])
return loft(layers, cap=True)
代码解释: 这段函数接收机身的中心坐标、高度、半宽半深与两个方向的圆角半径,输出一组顶点与面索引,构成一个封闭的圆角长方体。处理逻辑分三段:前两段是下半与上半的圆角层------按参数 t 从 0 到 1 采样,用 1 - sqrt(1 - t²) 这个球面公式算出每一层截面的内缩量,中间插一层收缩量为 0 的"腰部",于是侧面保持竖直;第三段对每一层调用 rrect 生成水平圆角矩形的点列,再把所有层交给 loft 逐层连接并封盖。预期结果是一个 162 面、192 顶点的封闭实体,非水密边为 0。 第 18 行与第 22 行的 max(0.0, ...) 防的是浮点开根号越界,第 30 行的钳位防的是收缩量大于半宽时截面塌成一条线------这两处都是跑断言时才发现的边界。
几何层的第二个关键能力是离线自检 :Part.open_edges() 检查每条边是否恰好被两个面共用,Part.degenerate_faces() 用 Newell 法算真实面积而不是靠"顶点是否重合"来推断,Part.solids() 用并查集数连通块。三者一起能在渲染之前就发现结构性缺陷。
这个能力在本次抓到了一个真 bug :镜头前端的光圈叶片环和前圈内唇漏了 rotx(90) 与 move 变换,结果躺在世界原点、埋进机身内部------渲染图上完全看不出来,但它们是 3 个白占的闭合实体,而且让机身最低点从 z=0 变成 z=−1。归位之后,台面高度才相应地从 z=0 抬到 z=+1,恢复了 1 mm 的贴合。
五、材质层:不用一张贴图做皮革与拉丝金属
第二个必须程序化解的问题:在禁止任何贴图文件的前提下,怎么做出真皮的粒面和金属的丝纹。
答案是把"纹理"理解成两个可叠加的通道:凹凸(Bump)负责几何层的起伏 ,粗糙度与各向异性负责光学层的方向性。皮革只需要前者,金属拉丝必须两者都做。
皮革的关键发现是:Voronoi 纹理的 F1 模式,其 Distance 输出的空间分布天然就是"细胞丘状" ------每个细胞中心距最小、向外递增,正好对应真皮的粒面。直接用 Distance 当高度图,形态就对了一半;再用一层高频 Noise 与之混合,打散 Voronoi 天生的规整感。
python
def mat_leather(name, base_hex, scale=42.0, bump=0.72, rough=0.54,
noise_scale=210.0, mix_fac=0.42):
"""程序化皮革:Voronoi F1 的 Distance 输出天然呈"细胞丘状",
正是皮革粒面的形态;叠一层高频 Noise 打散 Voronoi 的规整感。
只用 Bump 通道,不碰粗糙度 ------ 皮革是漫反射,没有方向性高光。
"""
m = base_material(name)
nt = m.node_tree
bsdf = _find_bsdf(nt)
# ---- 纹理坐标用 Object 模式,避免依赖 UV 展开 ----
tc = nt.nodes.new("ShaderNodeTexCoord")
mp = nt.nodes.new("ShaderNodeMapping")
_in(mp, "Scale").default_value = (1.0, 1.0, 1.0)
nt.links.new(tc.outputs["Object"], _in(mp, "Vector"))
# ---- Voronoi F1:只有 F1 才有丘状 Distance ----
vor = nt.nodes.new("ShaderNodeTexVoronoi")
vor.feature = 'F1'
_in(vor, "Scale").default_value = scale
nt.links.new(mp.outputs["Vector"], _in(vor, "Vector"))
# ---- 高频 Noise:打散 Voronoi 天生的规整感 ----
noi = nt.nodes.new("ShaderNodeTexNoise")
_in(noi, "Scale").default_value = noise_scale
_in(noi, "Detail").default_value = 6.0
nt.links.new(mp.outputs["Vector"], _in(noi, "Vector"))
# ---- 混合后驱动 Bump:0.42 只打散不喧宾 ----
mix = nt.nodes.new("ShaderNodeMixRGB")
_in(mix, "Fac").default_value = mix_fac
nt.links.new(vor.outputs["Distance"], _in(mix, "Color1"))
nt.links.new(noi.outputs["Fac"], _in(mix, "Color2"))
bmp = nt.nodes.new("ShaderNodeBump")
_in(bmp, "Strength").default_value = bump
nt.links.new(mix.outputs["Color"], _in(bmp, "Height"))
nt.links.new(bmp.outputs["Normal"], _in(bsdf, "Normal"))
_in(bsdf, "Base Color").default_value = hex_lin(base_hex)
_in(bsdf, "Roughness").default_value = rough
return m
代码解释: 函数输入材质名与基础色十六进制值,输出一个节点已连好的 Principled 材质。处理链是:纹理坐标用 Object 模式(避免依赖 UV 展开,程序化模型通常没有 UV)→ 通过 Mapping 节点传入 → 并行喂给 Voronoi(Scale 42,模拟粒面大小)与 Noise(Scale 210,模拟纤维细纹)→ 两者按 0.42 的比例混入 MixRGB → 混出结果接到 Bump 节点的 Height,Bump 输出接 BSDF 的 Normal → 基础色与粗糙度直接写插槽。预期结果是机身上出现可见的、无重复感的粒面凹凸,且不产生任何方向性高光。 第 27 行的 0.42 是调出来的:给到 0.7 以上时噪声会盖掉 Voronoi 的细胞边界,皮革就变成了磨砂塑料。第 10 行用 Object 坐标而不是 Generated,是因为机身在建模中被多次平移,用局部坐标才能让纹理跟随物体。
金属拉丝则是另一套逻辑,两半缺一不可 :只用 Bump 会像磨砂塑料(有起伏但无方向),只用各向异性会像抛光镜面(有方向但无纹理)。正确做法是细密平行凹凸 + 各向异性高光 ------把 Mapping 的某一轴 Scale 拉大(本例 stretch = 52),使噪声在该方向的频率升高,形成平行丝纹;同时把 Anisotropic 提到 0.5 左右,让高光沿丝纹方向拉成一条亮带。
| 材质 | 基础色 | 凹凸策略 | 光学参数 | 用于 |
|---|---|---|---|---|
| 皮革 | #26242B |
Voronoi F1 + Noise 双层 Bump | rough 0.54 | 机身包皮 |
| 拉丝银 | #C9CBD0 |
Mapping 单向拉伸 + Bump 0.22 | rough 0.30 / aniso 0.50 | 顶盖、拨盘、镜筒环 |
| 深色金属 | #1C1B21 |
弱 Bump | rough 0.38 | 镜筒主体、内部件 |
| 镜片玻璃 | #0E131D |
无 | ior 1.66 / rough 0.028 / trans 0.42 / coat 0.95 | 前片与内层镜片 |
关于"概念上的纯黑" :最初皮革用的是 #17161A,在浅灰背景棚拍里直接塌成一块没有纹理的剪影------纯黑吸掉所有环境光,凹凸就看不见了。提到 #26242B(深灰黑)之后粒面才浮出来。这是一个反直觉但很稳定的规律:在明亮背景下,你要让暗部材质"不那么暗",它的细节才存在。
六、取景与曝光:两个"看起来很合理"的错误
任务要求"三分之四视角、主体占画面约 70%"。最直观的实现是算出模型包围盒,取其 8 个角点,投影到相机平面求跨度,然后调距离使跨度等于 70%。这个做法在透视投影下是错的,而且错得很隐蔽------第一版渲染出来主体只占画面 35%。
python
def setup_camera(objs):
"""三分之四视角 + 自动取景 + 投影居中。
⚠️ 不能用包围盒 8 角点估画面占比:透视投影下"镜头前端(最靠前)"与
"机身侧面(最靠外)"组合出的那个角点会投到画面外很远,把相机一路推远。
本项目第一版就是这么把主体压到 35% 的 ------ 必须取真实顶点的投影跨度。
"""
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens, cam_data.sensor_width = 58.0, 36.0
cam = bpy.data.objects.new("Cam", cam_data)
bpy.context.scene.collection.objects.link(cam)
bpy.context.scene.camera = cam
# ---- 第一步:收集顶点并求包围盒 ----
pts, xs, ys, zs = [], [], [], []
for o in objs:
if o.name.endswith("studio"):
continue # 摄影棚不参与取景
vs = o.data.vertices
for i in range(0, len(vs), max(1, len(vs) // 300)): # 均匀降采样
w = o.matrix_world @ vs[i].co
pts.append(w); xs.append(w.x); ys.append(w.y); zs.append(w.z)
bb = ((min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)))
ctr = tuple((bb[0][i] + bb[1][i]) * 0.5 for i in range(3))
# ---- 第二步:按方位 35 度、俯仰 31 度摆位 ----
az, el = math.radians(35.0), math.radians(31.0)
dist = math.dist(bb[0], bb[1]) * 0.5 / (0.70 * math.tan(HALF_FOV))
# ---- 第三步:迭代收敛画面占比到 70% ----
for _ in range(5):
cam.location = (ctr[0] + dist * math.cos(el) * math.sin(az),
ctr[1] - dist * math.cos(el) * math.cos(az),
ctr[2] + dist * math.sin(el))
cam.rotation_euler = _look_euler(cam.location, ctr)
bpy.context.view_layer.update()
us, ws = [], []
for p in pts:
co = world_to_camera_view(bpy.context.scene, cam, p)
if co.z > 0.0: # 只统计在相机前方的点
us.append(co.x); ws.append(co.y)
cov = max(max(us) - min(us), max(ws) - min(ws))
if abs(cov - 0.70) < 0.004: # 收敛即停
break
dist *= cov / 0.70
return cam
代码解释: 函数输入全部构件对象列表,输出一个位置与朝向已调好的相机。处理分三步:第一步 遍历所有网格顶点(按 len(vs)//300 降采样,把上万顶点压到约 300 个采样点,这一步决定了后面每轮迭代的耗时),收集世界坐标并聚合出包围盒与中心;第二步 按方位 35°、俯仰 31° 的公式把相机摆到中心外侧,_look_euler 用 to_track_quat('-Z', 'Y') 生成朝向;第三步 用 world_to_camera_view 把每个采样点投到归一化相机坐标,取 u、v 方向跨度的较大值作为实际画面占比,按 dist *= cov / 0.70 修正距离,最多迭代 5 次,收敛阈值 0.004。预期结果是覆盖率稳定落在 70.0% 附近,实测 70.2%。 关键在第 26 行取的是真实顶点的投影跨度------包围盒角点是几何体的外接组合,透视投影下"前极点"与"外极点"组合出的那个角点并不在物体表面上,会投到画面外很远。
第二个"看起来合理"的错误在居中 。我在第三步之后加了一段投影居中:用同样方式算出像的投影重心,求它到 (0.5, 0.5) 的偏差,再沿相机的右向量与上向量平移相机。逻辑没问题,但我把单次位移的上限写成了 cap = 0.22,注释写的是"最多挪 22% 画面"------而这个量在世界坐标里的单位是毫米,于是每次平移被钳到 0.22 mm,四轮迭代里相机几乎没动过,主体始终偏右约 3%。
修法只有一行:把上限改成以画面比例为单位、乘上等效画面宽度换算成毫米。修完之后投影中心实测 (0.500, 0.500),偏移 −0.00% / −0.03%。
| 项目 | 第一版 | 最终版 | 修法 |
|---|---|---|---|
| 主体画面占比 | 35% | 70.2% | 包围盒角点 → 真实顶点投影跨度 |
| 投影中心偏移 | 未校正 | −0.00% / −0.03% | 位移上限单位从"画面比例"改为毫米换算 |
| 背景亮度 | 中灰,欠曝 | 231.0 -- 232.8 | 世界环境与主光能量重配 |
| 过曝采样点 | 存在 | 0.00% | 银色拉丝粗糙度 0.22 → 0.30 |
自动取景与居中这两步的调用关系与收敛条件如下。注意整段逻辑没有任何一次人工调参,距离修正与居中位移全部由投影反馈算出:
投影采样 相机对象 场景脚本 投影采样 相机对象 场景脚本 #mermaid-svg-2VFGDNo5K2vOkjtH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2VFGDNo5K2vOkjtH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2VFGDNo5K2vOkjtH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2VFGDNo5K2vOkjtH .error-icon{fill:#552222;}#mermaid-svg-2VFGDNo5K2vOkjtH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2VFGDNo5K2vOkjtH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2VFGDNo5K2vOkjtH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2VFGDNo5K2vOkjtH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2VFGDNo5K2vOkjtH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2VFGDNo5K2vOkjtH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2VFGDNo5K2vOkjtH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2VFGDNo5K2vOkjtH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2VFGDNo5K2vOkjtH .marker.cross{stroke:#333333;}#mermaid-svg-2VFGDNo5K2vOkjtH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2VFGDNo5K2vOkjtH p{margin:0;}#mermaid-svg-2VFGDNo5K2vOkjtH .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2VFGDNo5K2vOkjtH text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-2VFGDNo5K2vOkjtH .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2VFGDNo5K2vOkjtH .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-2VFGDNo5K2vOkjtH .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-2VFGDNo5K2vOkjtH .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-2VFGDNo5K2vOkjtH #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-2VFGDNo5K2vOkjtH .sequenceNumber{fill:white;}#mermaid-svg-2VFGDNo5K2vOkjtH #sequencenumber{fill:#333;}#mermaid-svg-2VFGDNo5K2vOkjtH #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-2VFGDNo5K2vOkjtH .messageText{fill:#333;stroke:none;}#mermaid-svg-2VFGDNo5K2vOkjtH .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2VFGDNo5K2vOkjtH .labelText,#mermaid-svg-2VFGDNo5K2vOkjtH .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-2VFGDNo5K2vOkjtH .loopText,#mermaid-svg-2VFGDNo5K2vOkjtH .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-2VFGDNo5K2vOkjtH .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2VFGDNo5K2vOkjtH .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-2VFGDNo5K2vOkjtH .noteText,#mermaid-svg-2VFGDNo5K2vOkjtH .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-2VFGDNo5K2vOkjtH .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2VFGDNo5K2vOkjtH .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2VFGDNo5K2vOkjtH .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2VFGDNo5K2vOkjtH .actorPopupMenu{position:absolute;}#mermaid-svg-2VFGDNo5K2vOkjtH .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-2VFGDNo5K2vOkjtH .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2VFGDNo5K2vOkjtH .actor-man circle,#mermaid-svg-2VFGDNo5K2vOkjtH line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-2VFGDNo5K2vOkjtH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 与 0.70 的偏差小于 0.004 偏差仍超标 loop 最多迭代 5 次 按 35 度方位 31 度俯仰摆位 采样约 300 个顶点做投影 返回画面跨度 cov 判定收敛 结束迭代 距离乘以 cov 与 0.70 之比 沿右向量与上向量平移完成居中
图 3:自动取景与居中的收敛时序。两步都靠投影反馈闭环,不依赖手感。
图 4:第一版的问题现场------主体偏小偏右,背景是中灰而非设计的浅灰。
七、棚拍伪影定位法:从"改参数猜"到"量化定位"
这一节是全文的核心,因为它是这次唯一真正解决问题的方法。
7.1 问题现象与我推翻的五个假设
现象很单一:画面上方有一条横贯的亮度台阶,台阶上下的亮度差约 18 级。背景本该是一块均匀的浅灰,多出这条线就不可能是"杂志配图"。
我按直觉提出的五个假设,以及它们各自的验证方式与结论:
| # | 假设 | 验证方式 | 结论 |
|---|---|---|---|
| 1 | 摄影棚弧形过渡段的起点落在了画面内 | 反算画面上边缘视线与地面的交点(y≈709 mm),把弧形起点从 700 推到 1000 | 部分有效:斜分界线消失,但横向台阶仍在 |
| 2 | 背景板与地面被不同灯照亮 | 加一盏背景光专门打亮背板 | 无效,且台阶反而更明显 |
| 3 | 摄影棚轮廓是退化几何 | 用射线投射查询"每个像素命中的对象" | 排除:命中的是同一块地面 |
| 4 | 某盏灯的照射锥边缘 | 逐灯计算像素与灯锥的夹角 | 排除:无灯锥边界与该位置吻合 |
| 5 | 地面自身的着色假影(shadow acne) | 用像素剖面读台阶上下实际色值 | 排除:数值与世界环境色吻合 |
五个假设全部落空。而真正让问题解决的不是第六个假设,是换了一个动作。
7.2 用像素剖面把"看起来不对"变成数字
我停止"改参数 → 渲染 → 目视",改为写一个探针脚本:直接读渲染出的 PNG,按固定间隔取竖直剖面与水平剖面的像素值,把亮度沿坐标打印出来。
python
def probe(path):
"""读渲染图做量化体检:背景均匀度 / 接触阴影深度 / 过曝比例。
⚠️ Blender 的 image.pixels 原点在左下角,且存的是线性值 ------ 必须转 sRGB
再和设计稿的 #RRGGBB 对齐,否则会得出"背景偏暗"的错误结论。
"""
# ---- 载入像素:原点在左下角,且存的是线性值 ----
img = bpy.data.images.load(path)
w, h = img.size
px = list(img.pixels) # RGBA 行优先,原点在左下
def srgb(v): # 线性 -> sRGB 显示值
return 255.0 * (12.92 * v if v <= 0.0031308
else 1.055 * (v ** (1 / 2.4)) - 0.055)
def at(x, y): # y 以左上角为原点,便于对着图取点
i = ((h - 1 - y) * w + x) * 4
return srgb(px[i]), srgb(px[i + 1]), srgb(px[i + 2])
# ---- 三组指标:背景均匀度、接触阴影、过曝比例 ----
col = [at(40, y) for y in range(2, h, 20)] # 左侧竖直剖面:查背景台阶
lum = [sum(c) / 3.0 for c in col]
print("背景波动 %.1f 级 (%.1f .. %.1f)" % (max(lum) - min(lum),
min(lum), max(lum)))
row = [at(x, int(h * 0.62)) for x in range(2, w, 33)]
print("贴地一行 : " + " ".join("%.0f" % (sum(c) / 3.0) for c in row))
over = sum(1 for i in range(0, len(px), 4) if srgb(px[i]) > 252)
print("过曝比例 : %.2f%% (峰值 %.1f)"
% (100.0 * over / (w * h), max(srgb(px[i]) for i in range(0, len(px), 4))))
代码解释: 函数输入渲染图路径,输出三组量化指标。处理顺序是:用 bpy.data.images.load 载入 PNG,把 pixels 摊平成列表(注意原点在左下角 ,与看图习惯相反,所以 at() 里做了 h - 1 - y 的行翻转);srgb() 把 Blender 存的线性值转成 sRGB 显示值------这一步是关键,跳过它就会把线性 0.76 当成 194 而不是实际显示的 230,得出"背景偏暗"的错误结论 ;然后分别取 x=40 的竖直剖面(专门查背景台阶)、y=0.62H 的水平剖面(查接触阴影那圈暗带)、以及全图统计 >252 的过曝比例。预期结果是三个数:背景波动应小于 2 级、接触阴影一行应出现明显的 V 形凹陷、过曝比例应为 0。
这个探针一次性给出了决定性答案。台阶上方 实测 (244, 246, 250),而按世界环境 (0.70, 0.71, 0.74) × 1.30 换算出的 sRGB 是 (244.6, 246.2, 250.7)------差在 1 级以内 。结论当场成立:那条带就是世界背景,它从摄影棚的边缘露了出来 。而射线投射说"命中的是地面",是因为射线查询的是几何求交,而渲染走的是着色路径,两者的边界判定并不一致------当两个诊断手段给出矛盾结论时,相信给出数值的那个。

7.3 修法:把世界对"相机射线"的颜色单独校准
根因清楚之后,修法就变得很小。世界背景在 Blender 里有两个角色:提供环境照明 ,以及作为相机射线的兜底颜色(光线打不到任何物体时显示的颜色)。原来两者共用同一个 Background 节点,于是"环境照明需要多亮"与"背景该显示成什么灰"被迫绑定,怎么调都会在摄影棚边缘露出色差。
正确做法是用 ShaderNodeLightPath 的 Is Camera Ray 输出做分流:非相机射线走照明用的背景色,相机射线走单独校准过的显示色。
python
def split_world(world, cam_hex):
"""把世界背景拆成"照明色"与"相机射线色"两条支路。
背景等于摄影棚的统一浅灰,而世界环境同时还要承担金属材质的反射光源,
两个诉求强度不同 ------ 用一个 Light Path 节点分流即可解耦。
"""
# ---- 按 bl_idname 定位节点,避免依赖默认节点名 ----
nt = world.node_tree
out_node = next(n for n in nt.nodes if n.bl_idname == "ShaderNodeOutputWorld")
bg_light = next(n for n in nt.nodes if n.bl_idname == "ShaderNodeBackground")
bg_cam = nt.nodes.new("ShaderNodeBackground")
bg_cam.inputs["Color"].default_value = hex_lin(cam_hex)
bg_cam.inputs["Strength"].default_value = 1.0
# ---- Is Camera Ray 为 1 时输出相机射线色 ----
lp = nt.nodes.new("ShaderNodeLightPath")
mix = nt.nodes.new("ShaderNodeMixShader")
for lk in list(out_node.inputs["Surface"].links): # 先拆掉原有连接
nt.links.remove(lk)
nt.links.new(lp.outputs["Is Camera Ray"], mix.inputs["Fac"])
nt.links.new(bg_light.outputs["Background"], mix.inputs[1]) # 非相机射线
nt.links.new(bg_cam.outputs["Background"], mix.inputs[2]) # 相机射线
nt.links.new(mix.outputs["Shader"], out_node.inputs["Surface"])
return bg_cam
代码解释: 函数接收世界对象与一个目标十六进制色,返回新建的相机射线背景节点。处理逻辑是:先按 bl_idname 找到输出节点与原有背景节点(不要按节点名索引,默认名会随语言版本变化 ),新建第二个 Background 节点并写入相机射线色,再插一个 LightPath 与 MixShader;用 Is Camera Ray 作为混合因子------因子为 0(非相机射线/照明路径)时输出第 1 个输入的着色器,为 1(相机射线)时输出第 2 个。预期结果是环境照明强度与背景显示色彻底解耦:调照明不再改变背景灰度,调背景不再影响金属亮度。
在此基础上,脚本还做了一步自校准:正式渲染前先渲一张 16×16 的探针图,读回地面实际渲染色,再按它反算相机射线世界色的线性值,然后才出正片。这样"背景显示成设计稿的 #E8E8E8"就不再依赖人工对齐,而是每次运行自动成立。实测最终背景为 231.0--232.8,波动 ≤1.8 级,正好落在 #E8E8E8(232)。
7.4 五类棚拍伪影的定位对照表
把这次的经历与往次建模任务合并,我整理出一张可直接查的表。它的用法是:看到现象,先查表定位手段,而不是先改参数。
| 现象 | 最可能的根因 | 定位手段 | 修法方向 |
|---|---|---|---|
| 背景出现横向或斜向亮度台阶 | 世界背景从摄影棚边缘露出 / 弧形过渡起点在画面内 | 像素竖直剖面:实测色值 vs 环境色换算值 | Light Path 分流 + 弧形起点推到画面外 |
| 主体像悬空,接触处无暗部 | AO 尺度错配(screen_trace_thickness 按米给、场景按毫米建) |
检查建模单位与默认值的量级差 | 厚度按单位比放大(本例 0.1 → 4.0) |
| 阴影几乎看不见,边缘过软 | 面光尺寸过大(AREA 灯柔影半径 ≈ 灯尺寸) | 反算灯尺寸与投距的比例 | 收到主体尺寸的 1.3 倍左右 |
| 暗部材质塌成无细节剪影 | "概念纯黑"在明亮背景下失去层次 | 直方图看暗部是否被压到同一值 | 基础色提到深灰黑,保留明度区间 |
| 主体占比或居中与设计值偏差大 | 用包围盒角点估占位 / 平移量的单位写错 | 用 world_to_camera_view 反解实际投影跨度 |
改用真实顶点采样 + 单位换算 |
#mermaid-svg-G8m1GVbnEHreFsgc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-G8m1GVbnEHreFsgc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-G8m1GVbnEHreFsgc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-G8m1GVbnEHreFsgc .error-icon{fill:#552222;}#mermaid-svg-G8m1GVbnEHreFsgc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-G8m1GVbnEHreFsgc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-G8m1GVbnEHreFsgc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-G8m1GVbnEHreFsgc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-G8m1GVbnEHreFsgc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-G8m1GVbnEHreFsgc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-G8m1GVbnEHreFsgc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-G8m1GVbnEHreFsgc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-G8m1GVbnEHreFsgc .marker.cross{stroke:#333333;}#mermaid-svg-G8m1GVbnEHreFsgc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-G8m1GVbnEHreFsgc p{margin:0;}#mermaid-svg-G8m1GVbnEHreFsgc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-G8m1GVbnEHreFsgc .cluster-label text{fill:#333;}#mermaid-svg-G8m1GVbnEHreFsgc .cluster-label span{color:#333;}#mermaid-svg-G8m1GVbnEHreFsgc .cluster-label span p{background-color:transparent;}#mermaid-svg-G8m1GVbnEHreFsgc .label text,#mermaid-svg-G8m1GVbnEHreFsgc span{fill:#333;color:#333;}#mermaid-svg-G8m1GVbnEHreFsgc .node rect,#mermaid-svg-G8m1GVbnEHreFsgc .node circle,#mermaid-svg-G8m1GVbnEHreFsgc .node ellipse,#mermaid-svg-G8m1GVbnEHreFsgc .node polygon,#mermaid-svg-G8m1GVbnEHreFsgc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-G8m1GVbnEHreFsgc .rough-node .label text,#mermaid-svg-G8m1GVbnEHreFsgc .node .label text,#mermaid-svg-G8m1GVbnEHreFsgc .image-shape .label,#mermaid-svg-G8m1GVbnEHreFsgc .icon-shape .label{text-anchor:middle;}#mermaid-svg-G8m1GVbnEHreFsgc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-G8m1GVbnEHreFsgc .rough-node .label,#mermaid-svg-G8m1GVbnEHreFsgc .node .label,#mermaid-svg-G8m1GVbnEHreFsgc .image-shape .label,#mermaid-svg-G8m1GVbnEHreFsgc .icon-shape .label{text-align:center;}#mermaid-svg-G8m1GVbnEHreFsgc .node.clickable{cursor:pointer;}#mermaid-svg-G8m1GVbnEHreFsgc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-G8m1GVbnEHreFsgc .arrowheadPath{fill:#333333;}#mermaid-svg-G8m1GVbnEHreFsgc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-G8m1GVbnEHreFsgc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-G8m1GVbnEHreFsgc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-G8m1GVbnEHreFsgc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-G8m1GVbnEHreFsgc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-G8m1GVbnEHreFsgc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-G8m1GVbnEHreFsgc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-G8m1GVbnEHreFsgc .cluster text{fill:#333;}#mermaid-svg-G8m1GVbnEHreFsgc .cluster span{color:#333;}#mermaid-svg-G8m1GVbnEHreFsgc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-G8m1GVbnEHreFsgc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-G8m1GVbnEHreFsgc rect.text{fill:none;stroke-width:0;}#mermaid-svg-G8m1GVbnEHreFsgc .icon-shape,#mermaid-svg-G8m1GVbnEHreFsgc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-G8m1GVbnEHreFsgc .icon-shape p,#mermaid-svg-G8m1GVbnEHreFsgc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-G8m1GVbnEHreFsgc .icon-shape .label rect,#mermaid-svg-G8m1GVbnEHreFsgc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-G8m1GVbnEHreFsgc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-G8m1GVbnEHreFsgc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-G8m1GVbnEHreFsgc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不能
能
吻合
不吻合
不一致
一致
发现画面异常
能否量化
提出假设
改参数重渲
写像素剖面探针
读实测数值
与世界色或设计值吻合
锁定为世界背景或光照边界
做射线投射查命中对象
几何命中与着色是否一致
信任数值一方
回到几何或材质层排查
最小改动修复
图 5:伪影定位决策树。核心是第一步就去判断"这个现象能不能被量化",能则一律先量化。
八、运行验证:量化体检结果
所有指标都来自脚本自动输出与像素探针读数,不是目视判断。
| 检验项 | 结果 | 判定 |
|---|---|---|
| 水密性(全部 8 个构件) | 非水密边 0 | ✓ |
| 退化面(Newell 法面积) | 0 | ✓ |
| 闭合实体数 | 63 | ✓ |
| 顶点 / 面数 | 27,094 / 21,465 | --- |
| 主体画面占比 | 70.2% | ✓(目标 70%) |
| 投影中心偏移 | −0.00% / −0.03% | ✓ |
| 背景均匀度 | 231.0 -- 232.8,波动 ≤1.8 级 | ✓ |
| 过曝采样点 | 0 / 17,956(0.00%) | ✓ |
| 接触阴影深度 | 232 → 220(12 级) | ✓ |
| 外部资源依赖 | 模型 0 / 贴图 0 / HDR 0 | ✓ |
图 6:最终正片。背景为无缝浅灰,主体下方有自然接触阴影。
8.1 最花心思的一处细节:镜头前组
镜头是整台相机的视觉中心。它最初的表现是全图最差的地方------最前片读起来像一块哑光塑料盘,平淡、偏亮、没有镀膜玻璃该有的深色与反射层次。
排查时我用射线探针才发现,原因不在材质而在几何结构:镜头前端是一块实心的深色端面板 。因为 revolve 原语靠 loft 封盖,环状剖面会被端盖把中间的孔填掉,做不出通孔------于是整组镜片其实全部埋在这块面板里,唯一可见的只有前片从盘面上凸出来的那一顶"帽子"。
这个物理约束反推出正确做法:前片必须凸出盘面,且半径明显小于盘面 。把前镜片半径从 26 mm 收到 22 mm、拱度从 7.2 mm 提到 10.5 mm 之后,盘面上只露出半径约 18.1 mm 的玻璃,外面留出约 13.4 mm 的深色暗环。凹陷感加上大拱度带来的明暗渐变,玻璃才立住了------这是本次最满意的细节。
图 7:镜头前组。暗环的凹陷感是这一处从"塑料盘"变成"玻璃"的关键。
[
图 8:顶盖拨盘与过片扳手的滚花细节
图 9:迭代中期第 9 轮的整体状态
九、踩坑复盘:11 轮里真正吃掉时间的是什么
| # | 坑 | 现象 | 根因 | 成本 |
|---|---|---|---|---|
| 1 | 包围盒角点估占位 | 主体仅占画面 35% | 透视投影下角点不在物体表面上 | 1 轮 |
| 2 | AO 尺度错配 | 接触阴影完全不可见 | screen_trace_thickness 默认按米给 |
1 轮 |
| 3 | 面光尺寸过大 | 阴影过软,几乎看不见 | AREA 灯柔影半径 ≈ 灯尺寸 | 1 轮 |
| 4 | 概念纯黑 | 皮革塌成无纹理剪影 | 纯黑吸掉环境光,凹凸不可见 | 1 轮 |
| 5 | 背景光反噬 | 加背景光后分界更明显 | 背景光把地面与背板亮度拉开 | 1 轮 |
| 6 | 位移上限单位错 | 居中四轮几乎没动 | 按"画面比例"写却按毫米截断 | 4 轮 |
| 7 | 世界背景与照明耦合 | 台阶横贯画面 | 两个诉求共用一个背景节点 | 5 轮 |
| 8 | 变换漏写 | 两个圆环躺在世界原点 | 漏 rotx(90) 与 move |
渲染不可见,靠断言抓出 |
这张表里最该被记住的是第 6、7 两行:它们的共同特征是"逻辑看起来完全正确,只是单位或耦合关系错了"。 这类错误几乎无法通过目视发现,因为输出结果看起来只是"参数没调到"。
复盘出的可复用原则有三条:
第一,先量化,再假设。 前 7 轮我一直在"提假设 → 改参数 → 目视",五个假设全部落空。改用像素剖面之后,一轮锁定根因。判断标准很简单:如果这个现象能被写成某个数值与设计值的偏差,就先把它量出来。
第二,两个诊断手段给出矛盾结论时,相信给出数值的那个。 射线投射说"像素命中的是地面",像素剖面说"这个色值等于世界环境色"。前者是几何求交,后者是真实着色结果------渲染走的是着色路径,所以以后者为准。
第三,渲染之前先把能在离线验的东西验完。 几何层的断言抓到了两个渲染图上看不见的问题(躺在原点的圆环、重合的轮廓边)。这一步的成本是几十毫秒,收益是省掉整轮渲染的排查。

十、适用边界与风险提示
⚠️ 适用场景:本套方法适合"用程序化脚本产出产品级静物渲染"的任务------几何与材质全部可控、需要多轮参数迭代、对背景纯净度有要求、且不允许使用外部素材。
⚠️ 不适用场景:① 需要复杂有机形态(人物、布料、植物)时,程序化建模的代码量会远超收益,此路线不划算;② 需要照片级写实的产品渲染时,Eevee 的屏幕空间光追在多层透明与复杂互相反射场景下会漏光,此类需求应换 Cycles 或离线渲染器;③ 单次成型、不需要迭代的简单模型,直接手工建模更快。
⚠️ 版本风险 :bpy 的 4.x → 5.x 是一次破坏性升级------渲染引擎枚举只剩 BLENDER_EEVEE,use_nodes 已废弃(连读取都会告警,6.0 将移除),ShaderNodeTexMusgrave 被移除。老版本维护时,可用 ShaderNodeTexNoise 配合 ShaderNodeTexVoronoi 组合替代 Musgrave 的分形效果;本项目的 _in(node, name) 辅助函数按候选名列表查找插槽,就是为了兼容不同版本的插槽命名差异。
⚠️ 成本与回滚 :本任务单轮 800×800 / 144 采样渲染约 107 秒,预览模式(400×400 / 32 采样)约 62 至 84 秒。建议在改动前把上一版正片与 blend 备份 ------本次在第 9 轮正片之后、改造镜头结构之前做了备份(iter09_final_retro_camera.*),才能在发现镜头改动引入的贴合问题时对比回退。
⚠️ 一个尚未解决的取舍 :银色顶盖的峰值亮度为 233,未超过背景亮度,读起来是"缎面铝"而非"高光银"。这是刻意选择------环境一抬亮,接触阴影就会被吃掉。在"金属更像银"与"阴影更立体"之间,本场景选了后者。 如果你的场景背景更暗,可以反向取舍。
十一、总结
回到开头那个问题:一句"建一台复古相机"的需求,Agent 到底要自己回答什么?这次交付给出的答案是四件事------几何怎么用代码算出来 、材质怎么在不用贴图的前提下做出质感 、画面占比与居中怎么用数学收敛而不是靠手感 、以及渲染出的伪影怎么从"看起来不对"变成"量化定位到根因"。前三条靠可复用的代码机制解决,第四条靠一套诊断脚本解决,而第四条恰恰是本次耗时最多的一环。
11 轮迭代里,真正吃掉时间的不是建模,是把主观判断当成了定位手段 。前 7 轮我一直在"改参数 → 看结果 → 觉得还是不对",五个假设逐个被推翻;转向像素剖面与射线投射之后,一轮就锁定了根因:世界背景从摄影棚边缘露出。这条经验比任何一个 bpy API 都更值得带走------当视觉判断无法收敛时,先把问题变成数字。
本文涉及的几何算法(圆角长方体截面收缩、回转体滚花调制)、材质组合思路(Voronoi F1 做皮革、Bump 加各向异性做拉丝)与诊断方法(像素剖面、射线投射、离线几何断言)均不依赖具体版本,长期适用。
三个留给你思考的问题:① 如果背景不是浅灰而是纯白,那套"暗部材质要提亮"的规律还成立吗? ② 如果把迭代上限压到 5 轮,你会把量化探针放在第几轮引入? ③ 程序化材质在什么情况下会真的打不过贴图,那个临界点在哪里?



