摘要:本文记录一次由 WorkBuddy Agent 驱动、用 Blender 5.2 的 bpy 接口以纯代码方式建模的完整过程:从一句"建一个冬日微缩展示台"出发,经 12 轮迭代做出一座可复现的雪夜场景。全文主线不是建模技巧,而是三次"画面错了、代码没错"的隐形失效------渲染全空白、主体缩水一半、冰塘整块消失,它们的共同点是代码不报错、日志自报正常、连中间产物看上去都对。文中给出每一次的定位手段、可复现的验证脚本与完整证据链,适合已会写 Python、想在无独立显卡的机器上做程序化三维的读者。
文章目录
-
- [一、引言:一句"建一个冬日微缩展示台",Agent 要自己回答什么](#一、引言:一句"建一个冬日微缩展示台",Agent 要自己回答什么)
-
- [1.1 版本与时效说明](#1.1 版本与时效说明)
- [1.2 交付基线](#1.2 交付基线)
- 二、核心概念拆解
-
- [2.1 什么是 WorkBuddy Agent 模式:一条"看得见结果"的闭环](#2.1 什么是 WorkBuddy Agent 模式:一条"看得见结果"的闭环)
- [2.2 什么是 bpy 程序化建模:把模型写成代码](#2.2 什么是 bpy 程序化建模:把模型写成代码)
- [2.3 什么是微缩展示台(diorama):尺度是第一性问题](#2.3 什么是微缩展示台(diorama):尺度是第一性问题)
- [2.4 什么是"隐形失效":本文的故障分类](#2.4 什么是"隐形失效":本文的故障分类)
- 三、环境基线与整体架构
-
- [3.1 环境基线](#3.1 环境基线)
- [3.2 整体架构](#3.2 整体架构)
- [3.3 一个必须先做的决策:引擎选 Cycles 还是 Eevee](#3.3 一个必须先做的决策:引擎选 Cycles 还是 Eevee)
- [四、几何层:一个不依赖 bpy 的纯几何库](#四、几何层:一个不依赖 bpy 的纯几何库)
-
- [4.1 为什么要把几何库独立出来](#4.1 为什么要把几何库独立出来)
- [4.2 核心原语:双层参数曲面壳](#4.2 核心原语:双层参数曲面壳)
- [4.3 几何库的分层](#4.3 几何库的分层)
- 五、三次隐形失效的定位实录
-
- [5.1 失效一:整幅画面只剩背景色](#5.1 失效一:整幅画面只剩背景色)
- [5.2 失效二:日志写 75.9%,画面上主体只占 44%](#5.2 失效二:日志写 75.9%,画面上主体只占 44%)
- [5.3 失效三:整块消失的冰塘](#5.3 失效三:整块消失的冰塘)
- 六、验证:怎么把"我觉得"变成"我量过"
-
- [6.1 离线几何自检:2 秒跑完 10 万面](#6.1 离线几何自检:2 秒跑完 10 万面)
- [6.2 局部裁剪放大:判断材质细节的唯一便宜办法](#6.2 局部裁剪放大:判断材质细节的唯一便宜办法)
- [6.3 像素级一致性验证:别用整文件 MD5](#6.3 像素级一致性验证:别用整文件 MD5)
- [6.4 渲染预算:为什么迭代一律走预览档](#6.4 渲染预算:为什么迭代一律走预览档)
- 七、最花心思的一处细节:螺旋鳞片屋顶
- 八、适用边界与风险提示
-
- [8.1 什么时候该用这条路,什么时候不该](#8.1 什么时候该用这条路,什么时候不该)
- [8.2 三个具体风险](#8.2 三个具体风险)
- [8.3 一套可迁移的排查纪律](#8.3 一套可迁移的排查纪律)
- 九、总结
- 参考资料
一、引言:一句"建一个冬日微缩展示台",Agent 要自己回答什么
任务原文很短,一句话:建一个微缩展示台场景(diorama)------圆角方形展示底座上坐落一间小屋及其院落;小屋造型要有创意、避免大众化;院落景观自由设计;季节是冬天;整体要让人眼前一亮。渲染图、脚本、blend 文件保存到 diorama-winter/。
这句话里有四个约束,其中三个是"开放式"的:什么叫有创意、什么叫眼前一亮、院落该长什么样。Agent 模式的价值恰好在这里------它不需要我先把设计定下来再执行,而是可以自己提出概念、自己实现、自己看渲染结果、自己推翻重来。最终定下的概念是**「冷杉球果屋」**:把冷杉球果的螺旋鳞片变成屋顶的一座圆屋,立在覆雪的圆角台上,冬夜蓝调时刻,窗光与灯串是画面里唯一的暖色重音。
但真正吃掉大部分时间的不是造型,而是三次"看不出来"的故障。它们有一个共同的形状:
- 代码没有抛异常;
- 日志自报的指标(包围盒、覆盖率、物体数)全部落在合理范围;
- 甚至中间产物看起来也是"对"的------材质 ID 调试图上该有的颜色一个不少;
- 而画面是错的。
我把这一类问题叫作隐形失效。它和"报错"是两种完全不同的东西:报错会告诉你哪里不对,隐形失效只会给你一个看起来正常的坏结果。这篇文章的主要篇幅就是这三次定位的实录,因为我认为它比"怎么做一个球果屋顶"更有复用价值。
1.1 版本与时效说明
本文基于 Blender 5.2.2 LTS + Python 3.13,在 Windows 10 上以 blender --background --python 无头模式运行,渲染引擎为 Cycles CPU。
需要区分两类内容:几何构造、光学公式、参数曲面这些属于长期适用的原理,不依赖具体版本,跨版本照用 ;而版本敏感的部分集中在三处 API 变更 ------渲染引擎的可用性枚举、合成器节点树的重构、废弃属性的读写行为。本文会在对应小节用 ⚠️ 标出,并给出 4.x 的替代方案。
⚠️ 已知失效点 :Blender 5.0 起 Scene.node_tree 已被移除、Scene.use_nodes 已废弃并计划在 6.0 删除,本文第六节的合成器写法不再推荐 照搬到 4.x;4.x 用户请按当节的兼容性提示替换。如果你在更早的版本上复现,请先看第六节的更新说明。
1.2 交付基线
最终交付物的量化指标如下。这些数字不是形容词,而是后面每一节结论的依据。
| 项目 | 指标 |
|---|---|
| 几何规模 | 108,728 顶点 / 103,820 面 / 822 个闭合实体 |
| 关键构件计数 | 屋顶鳞片 154 片、檐下冰柱 36 根、飘雪 150 片 |
| 离线几何自检 | 非水密边 0、退化面 0 |
| 正片渲染 | 1200×1200 / 144 采样 / Cycles CPU / 481.5 秒 |
| 预览渲染 | 600×600 / 48 采样 / 53.2 秒 |
| 硬依赖 | 仅 Blender 内置模块(bpy / bmesh / mathutils / numpy) |
一句话概括这条基线:零外部资源、零贴图、零手工建模,全部几何与材质由 Python 代码生成。

图 1:终稿渲染。冷杉球果屋顶、冰塘、踏石小径与灯串,Cycles CPU 481.5 秒
二、核心概念拆解
标题里出现了几个技术词,在进入实现之前逐个说清楚。这一节不求全,只讲"它是什么、解决什么问题、核心机制在哪"------因为后面三次隐形失效的根因,恰好分别落在这几个概念的交界处。

图 2:12 轮迭代总览。p1--p3 是三次故障版(琥珀色边框),p12 为定稿
2.1 什么是 WorkBuddy Agent 模式:一条"看得见结果"的闭环
普通的代码生成是开环的:写代码 → 交给人 → 人跑 → 人看结果 → 人反馈。三种能力缺口里,模型只补了第一种。
Agent 模式补的是后两种:它能执行 (在本地起 Blender 进程、读日志、看渲染图),也能判断(对比预期与实际、发现不一致、决定下一步改什么)。这个差别在这类任务上被放得很明显------三维渲染是典型的"代码正确但结果错误"高发区,因为从代码到画面之间隔着裁剪面、投影矩阵、色彩管理、采样密度、光照能量等一大串中间环节。
所以本文里 Agent 干的事可以概括成一句:它不只是写脚本,它还给自己的脚本当验收员。而这恰好也解释了为什么三次隐形失效最终能被定位------如果只有开环,这三次都会表现为"图不对,但不知道哪不对",然后退化成反复调参。第 2.4 节会把这条推理补完。
2.2 什么是 bpy 程序化建模:把模型写成代码
bpy 是 Blender 的 Python 接口,能拿到场景里的一切:网格、材质、灯光、相机、渲染设置。程序化建模就是用这些接口把几何体算出来,而不是在界面里拖拽。
它相对手工建模有三个不可替代的优势,也各有一个代价:
| 优势 | 代价 |
|---|---|
| 参数化------所有尺寸集中在一处,改一个数就能重生成整座建筑 | 前期要写几何原语库,投入在前 |
| 可复现------同一份脚本在任何机器上产出同样的模型 | 环境的版本差异会变成"隐形失效"(本文主线) |
| 可验证------几何能在不渲染的前提下被程序检查 | 需要额外写自检代码,否则错误会一路带到渲染 |
第三条是本文后面反复用到的关键能力。当一个模型有 10 万个面时,人眼在渲染图上永远无法判断"有没有退化面";但代码可以在 2 秒内数清楚。第 6.1 节会给出具体实现。
2.3 什么是微缩展示台(diorama):尺度是第一性问题
微缩展示台 (diorama)是"把一小片世界装进一个底座"的表现形式:它必须同时是两个东西------一个完整的场景 ,和一个可以被拿起来看的物件。这个双重身份决定了几何设计上的两条硬规则:
- 必须有物理边界。台子要有厚度、有侧壁、有圆角,让观众一眼看出"这是一个模型"。少了这层边界,它就只是一座建筑。
- 必须有尺度线索 。台面上要放那些"只有俯视才能看见"的东西:脚印、踏石、柴堆、雪橇、鸟屋。它们本身不构成景观,但它们是观众判断自己有多大的唯一依据。
而本文采用的建模单位是毫米 (台面 320mm 见方,房屋高约 148mm)。选毫米是因为它让参数读起来直观------写 wall_r = 41.5 时脑子里就是 4 厘米的墙------但毫米会带来三个直接后果,本文几乎所有硬件级故障都源自这三条。
| 毫米尺度的后果 | 具体表现 |
|---|---|
| 相机到主体距离达 1300+ | 撞上 clip_end 默认值 1000,整个模型被裁掉 |
| 点光源照度按 1/d² 衰减,30 单位相当于"30 米" | 灯光能量只需几瓦,而不是几百瓦 |
噪声纹理的 Scale 是特征尺寸的倒数 |
Scale=240 在毫米下特征只有 0.004mm,噪点被抹平 |
⚠️ 这三条的共同特征是:它们都不会让代码报错。第一条让画面全空,第二条让画面死黑,第三条让材质变平滑------全都表现为"渲染结果不对劲",而代码本身无懈可击。这也是为什么第 2.4 节要专门给这类问题分个类。
2.4 什么是"隐形失效":本文的故障分类
我把三维程序化建模中遇到的故障分成三类。这个分类不是为了学术,而是因为它直接决定你下一步该做什么。
| 类型 | 特征 | 该做什么 | 本文对应 |
|---|---|---|---|
| 显性故障 | 抛异常、栈回溯、进程退出 | 读错误信息,直接定位 | 无(本文脚本一次跑通) |
| 参数故障 | 画面不对,且能靠经验猜到参数 | 改参数、重渲、看结果 | dof 光圈值失效 |
| 隐形失效 | 画面不对,代码与日志都自报正常 | 先造观测,不要改参数 | 本文三次 |
第三类的危险在于它会诱使你走错路。我在这三次里一共推翻了 7 个假设------而每一个被推翻的假设,都是因为我先去改了参数、然后发现"改了也没用"。这个弯路走完之后我总结出一条纪律,后面三次定位靠的都是它:
当渲染结果与代码逻辑矛盾时,不要去改参数,先造一个能证伪的观测。
改参数是在猜测空间 里搜索,而猜测空间的维度是无限的;造观测是把问题压到一个数字或一个布尔值 上,让它自己回答。第 5.1 节你会看到一个极简的例子:一行 scene.ray_cast 就把"几何在不在"从"我觉得"变成了"命中,距离 1341.7"。
下面这张图是三次失效的观测路径与最终根因,可以当作全文的地图。
#mermaid-svg-ipUdtvO9vO55p2Wn{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-ipUdtvO9vO55p2Wn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ipUdtvO9vO55p2Wn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ipUdtvO9vO55p2Wn .error-icon{fill:#552222;}#mermaid-svg-ipUdtvO9vO55p2Wn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ipUdtvO9vO55p2Wn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ipUdtvO9vO55p2Wn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ipUdtvO9vO55p2Wn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ipUdtvO9vO55p2Wn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ipUdtvO9vO55p2Wn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ipUdtvO9vO55p2Wn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ipUdtvO9vO55p2Wn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ipUdtvO9vO55p2Wn .marker.cross{stroke:#333333;}#mermaid-svg-ipUdtvO9vO55p2Wn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ipUdtvO9vO55p2Wn p{margin:0;}#mermaid-svg-ipUdtvO9vO55p2Wn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ipUdtvO9vO55p2Wn .cluster-label text{fill:#333;}#mermaid-svg-ipUdtvO9vO55p2Wn .cluster-label span{color:#333;}#mermaid-svg-ipUdtvO9vO55p2Wn .cluster-label span p{background-color:transparent;}#mermaid-svg-ipUdtvO9vO55p2Wn .label text,#mermaid-svg-ipUdtvO9vO55p2Wn span{fill:#333;color:#333;}#mermaid-svg-ipUdtvO9vO55p2Wn .node rect,#mermaid-svg-ipUdtvO9vO55p2Wn .node circle,#mermaid-svg-ipUdtvO9vO55p2Wn .node ellipse,#mermaid-svg-ipUdtvO9vO55p2Wn .node polygon,#mermaid-svg-ipUdtvO9vO55p2Wn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ipUdtvO9vO55p2Wn .rough-node .label text,#mermaid-svg-ipUdtvO9vO55p2Wn .node .label text,#mermaid-svg-ipUdtvO9vO55p2Wn .image-shape .label,#mermaid-svg-ipUdtvO9vO55p2Wn .icon-shape .label{text-anchor:middle;}#mermaid-svg-ipUdtvO9vO55p2Wn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ipUdtvO9vO55p2Wn .rough-node .label,#mermaid-svg-ipUdtvO9vO55p2Wn .node .label,#mermaid-svg-ipUdtvO9vO55p2Wn .image-shape .label,#mermaid-svg-ipUdtvO9vO55p2Wn .icon-shape .label{text-align:center;}#mermaid-svg-ipUdtvO9vO55p2Wn .node.clickable{cursor:pointer;}#mermaid-svg-ipUdtvO9vO55p2Wn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ipUdtvO9vO55p2Wn .arrowheadPath{fill:#333333;}#mermaid-svg-ipUdtvO9vO55p2Wn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ipUdtvO9vO55p2Wn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ipUdtvO9vO55p2Wn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ipUdtvO9vO55p2Wn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ipUdtvO9vO55p2Wn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ipUdtvO9vO55p2Wn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ipUdtvO9vO55p2Wn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ipUdtvO9vO55p2Wn .cluster text{fill:#333;}#mermaid-svg-ipUdtvO9vO55p2Wn .cluster span{color:#333;}#mermaid-svg-ipUdtvO9vO55p2Wn 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-ipUdtvO9vO55p2Wn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ipUdtvO9vO55p2Wn rect.text{fill:none;stroke-width:0;}#mermaid-svg-ipUdtvO9vO55p2Wn .icon-shape,#mermaid-svg-ipUdtvO9vO55p2Wn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ipUdtvO9vO55p2Wn .icon-shape p,#mermaid-svg-ipUdtvO9vO55p2Wn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ipUdtvO9vO55p2Wn .icon-shape .label rect,#mermaid-svg-ipUdtvO9vO55p2Wn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ipUdtvO9vO55p2Wn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ipUdtvO9vO55p2Wn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ipUdtvO9vO55p2Wn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 失效一
画面全空白
ray_cast 垂直向下
命中雪面,距离 1341.7
根因:clip_end 默认 1000
修法:显式设 80000
失效二
日志 75.9% 画面 44%
打印分辨率
与 sensor_fit
根因:取景跑在
setup_render 之前
修法:调换调用顺序
失效三
冰塘整块消失
材质 ID 调试图
也找不到青色
根因:高度场挖坑
不会在实心板上开洞
修法:掩码 + 固定高度常量
图 3:三次隐形失效的观测路径与根因。注意每个失效的第二步都不是"改参数"

三、环境基线与整体架构
3.1 环境基线
实操类内容必须先交代运行条件。下表是这次任务的完整环境,第三列的"影响"栏是重点------它解释了为什么后面某些结论只在这类机器上成立。
| 项目 | 本次配置 | 为何重要 |
|---|---|---|
| 操作系统 | Windows 10 Pro 22H2 | 路径分隔符与字体可用性相关 |
| Blender | 5.2.2 LTS | 合成器与引擎枚举的 API 变更集中在此版本区间 |
| Python | 3.13(Blender 内置) | bpy 只能用它自带的解释器,不能换成系统 Python |
| CPU | Intel i5-9400(6 核 6 线程) | Cycles 的采样自适应依赖核数,耗时按核数缩放 |
| 显卡 | 无独立显卡 | 直接决定了第 3.3 节的引擎选择结论 |
| 内存 | 16 GB | 10 万面级别的场景毫无压力,瓶颈不在内存 |
| 外部依赖 | 无(零贴图、零模型库、零插件) | 脚本可在任何装了 Blender 的机器上复现 |
一个容易被忽略的点:bpy 脚本必须由 Blender 自带的解释器执行 ,用系统 Python 直接 import bpy 会失败。所以我后面写的所有诊断脚本,入口都是 blender --background --python xxx.py,而不是 python xxx.py。唯一例外是那部分不依赖 bpy 的纯几何代码------这正是第 4.1 节要把几何库独立出来的实际收益。
3.2 整体架构
脚本分成六层,从下往上依次是几何原语、参数、材质、放置工具、构件、主脚本。这个分层的直接好处在迭代阶段显现:改造型只动 dparams.py 一个文件,改材质只动 dmat.py,两者不会互相牵连。
#mermaid-svg-CLu4feTW0NBTAQ2r{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-CLu4feTW0NBTAQ2r .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CLu4feTW0NBTAQ2r .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CLu4feTW0NBTAQ2r .error-icon{fill:#552222;}#mermaid-svg-CLu4feTW0NBTAQ2r .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CLu4feTW0NBTAQ2r .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CLu4feTW0NBTAQ2r .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CLu4feTW0NBTAQ2r .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CLu4feTW0NBTAQ2r .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CLu4feTW0NBTAQ2r .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CLu4feTW0NBTAQ2r .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CLu4feTW0NBTAQ2r .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CLu4feTW0NBTAQ2r .marker.cross{stroke:#333333;}#mermaid-svg-CLu4feTW0NBTAQ2r svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CLu4feTW0NBTAQ2r p{margin:0;}#mermaid-svg-CLu4feTW0NBTAQ2r .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CLu4feTW0NBTAQ2r .cluster-label text{fill:#333;}#mermaid-svg-CLu4feTW0NBTAQ2r .cluster-label span{color:#333;}#mermaid-svg-CLu4feTW0NBTAQ2r .cluster-label span p{background-color:transparent;}#mermaid-svg-CLu4feTW0NBTAQ2r .label text,#mermaid-svg-CLu4feTW0NBTAQ2r span{fill:#333;color:#333;}#mermaid-svg-CLu4feTW0NBTAQ2r .node rect,#mermaid-svg-CLu4feTW0NBTAQ2r .node circle,#mermaid-svg-CLu4feTW0NBTAQ2r .node ellipse,#mermaid-svg-CLu4feTW0NBTAQ2r .node polygon,#mermaid-svg-CLu4feTW0NBTAQ2r .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CLu4feTW0NBTAQ2r .rough-node .label text,#mermaid-svg-CLu4feTW0NBTAQ2r .node .label text,#mermaid-svg-CLu4feTW0NBTAQ2r .image-shape .label,#mermaid-svg-CLu4feTW0NBTAQ2r .icon-shape .label{text-anchor:middle;}#mermaid-svg-CLu4feTW0NBTAQ2r .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CLu4feTW0NBTAQ2r .rough-node .label,#mermaid-svg-CLu4feTW0NBTAQ2r .node .label,#mermaid-svg-CLu4feTW0NBTAQ2r .image-shape .label,#mermaid-svg-CLu4feTW0NBTAQ2r .icon-shape .label{text-align:center;}#mermaid-svg-CLu4feTW0NBTAQ2r .node.clickable{cursor:pointer;}#mermaid-svg-CLu4feTW0NBTAQ2r .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CLu4feTW0NBTAQ2r .arrowheadPath{fill:#333333;}#mermaid-svg-CLu4feTW0NBTAQ2r .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CLu4feTW0NBTAQ2r .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CLu4feTW0NBTAQ2r .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CLu4feTW0NBTAQ2r .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CLu4feTW0NBTAQ2r .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CLu4feTW0NBTAQ2r .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CLu4feTW0NBTAQ2r .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CLu4feTW0NBTAQ2r .cluster text{fill:#333;}#mermaid-svg-CLu4feTW0NBTAQ2r .cluster span{color:#333;}#mermaid-svg-CLu4feTW0NBTAQ2r 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-CLu4feTW0NBTAQ2r .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CLu4feTW0NBTAQ2r rect.text{fill:none;stroke-width:0;}#mermaid-svg-CLu4feTW0NBTAQ2r .icon-shape,#mermaid-svg-CLu4feTW0NBTAQ2r .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CLu4feTW0NBTAQ2r .icon-shape p,#mermaid-svg-CLu4feTW0NBTAQ2r .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CLu4feTW0NBTAQ2r .icon-shape .label rect,#mermaid-svg-CLu4feTW0NBTAQ2r .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CLu4feTW0NBTAQ2r .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CLu4feTW0NBTAQ2r .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CLu4feTW0NBTAQ2r :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} dparams.py
全部尺寸参数
dgeo.py
纯几何原语
不依赖 bpy
构件层
dground / dhouse / dfit
dtree / dyard
dmat.py
程序化材质
噪声特征尺寸按毫米给
dutil.py
放置工具
沿圆周/折线/镜像
01_winter_diorama.py
主脚本:世界/灯光/相机/渲染/合成
output/
PNG + blend
验证闭环
离线几何自检 · 射线探针 · 局部裁剪放大
图 4:六层脚本架构。几何库刻意不依赖 bpy,才能被普通 Python 离线自检
3.3 一个必须先做的决策:引擎选 Cycles 还是 Eevee
在动手建模之前,我先花十分钟做了一个引擎对比实验 ,因为"用哪个引擎"会决定后面每一轮迭代的时间成本。当时的直觉是"Eevee 更快,因为它不做光线追踪"------这个直觉在这台机器上是错的,而错得很有教育意义。
实验设计:分别用一个空场景(只有一个立方体)在两种引擎下渲染,并且特意加测一个 120×120 的极小分辨率。
| 引擎 | 分辨率 | 耗时 | 结论 |
|---|---|---|---|
| Eevee | 480×480 | 36 秒 | --- |
| Eevee | 120×120 | 34 秒 | 分辨率降到 1/16,耗时几乎不变 |
| Cycles | 480×480(同场景) | 23 秒 | 比 Eevee 快 5.6 倍 |
120×120 那一行是关键证据:如果耗时主要来自像素计算,分辨率降 16 倍应该降到几秒。它没有降,说明耗时几乎全部是固定的初始化开销------这台机器没有独立显卡,Eevee 退化成了软件光栅化。所以"Eevee 更快"这条经验在此处不成立,Cycles CPU 反而更快,而且白拿真全局光照、真软阴影、真透射。
⚠️ 这里还埋着一个更隐蔽的坑。我最初写的是"先枚举可用引擎、再从中挑一个":
python
"""引擎选择:不要用 enum_items 判断可用性,直接 try 赋值并回读确认。"""
import bpy
# ---- 踩过的错法(留在这当反例)----
# eng = [i.identifier for i in
# bpy.context.scene.render.bl_rna.properties["engine"].enum_items]
# bpy.context.scene.render.engine = "CYCLES" if "CYCLES" in eng else eng[-1]
#
# 实测 eng 只返回 ['BLENDER_EEVEE'],偏偏不含 CYCLES。原因是 Cycles 属于
# 插件引擎、动态注册,不进这个枚举,但 scene.render.engine = 'CYCLES'
# 又是完全能用的。于是上面那句 if 一路走 fallback,脚本**静默地**跑在
# 软件 Eevee 上 ------ 只有日志里一行"引擎 = BLENDER_EEVEE"混在正常输出中间,
# 我看了两版预览才发现。
# 结论:判断引擎可用性,唯一可靠的方式是直接试赋值。
def pick_engine(prefer="CYCLES"):
"""把首选项排到最前,依次 try 赋值,返回真正生效的引擎名。
输入:期望的引擎名(默认 CYCLES)。
处理:按 [首选, EEVEE 新名, EEVEE 旧名] 顺序 try,第一个不抛
TypeError 的胜出 ------ 不同大版本的 EEVEE 标识符改过名字。
输出:实际生效的引擎名字符串。调用方必须用它,不能假定首选成功。
"""
scene = bpy.context.scene
order = [prefer]
for c in ("BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"):
if c not in order:
order.append(c)
for cand in order:
try:
scene.render.engine = cand
break
except TypeError:
continue
# ---- 回读确认:静默回退是这类问题最危险的地方 ----
actual = scene.render.engine
if actual != prefer:
print(" [警告] 无法切到 %s,实际引擎 = %s" % (prefer, actual))
return actual
这段代码的输入是一个期望的引擎名,输出是真正生效 的引擎名------注意这个函数的返回值与入参可能不同,而调用方必须使用返回值。处理逻辑是一个"有序尝试":把首选排在最前,后面依次是 EEVEE 的两个历届标识符(BLENDER_EEVEE 与 BLENDER_EEVEE_NEXT ------ 不同大版本改过名字,所以两个都要留),逐个 try 赋值,第一个不抛 TypeError 的胜出。最后一步是整段的关键:回读 scene.render.engine 并与首选比对,不一致就打警告 。预期结果是正常情况下打印 CYCLES、异常时给出一行显式警告。
这个回读动作看起来多余,实际上它才是修复的核心。原来的 bug 之所以能藏两版,不是因为 enum_items 不含 CYCLES 这个事实难懂,而是因为失败是静默的 ------程序没有崩、没有警告、没有异常,只是安静地降级到一个更慢的引擎上。凡是"静默降级"的设计,都要配一个显式的回读校验。
顺便说,这个小插曲是"隐形失效"的一个非典型代表:它不是渲染错了,而是渲染得太慢。如果一个脚本要跑 8 分钟,你不会觉得奇怪;只有当你知道同样的事 53 秒就能做完,你才意识到前面浪费了什么。
四、几何层:一个不依赖 bpy 的纯几何库
4.1 为什么要把几何库独立出来
整个 dgeo.py(约 800 行)不 import bpy 。它的函数只做一件事:吃进参数,吐出 (verts, faces) 两个纯 Python 列表。真正把几何送进 Blender 的是构件层。
这个约束看似多余,实际是本项目性价比最高的一个决定。它带来三个收益:
- 离线自检可以用普通 Python 跑。第 6.1 节的几何体检 2 秒出结果,不必启动 Blender(启动一次约 5 秒,而自检要跑几十次)。
- 几何错误和 Blender 错误能分开定位。如果顶点数是错的,那问题一定在几何层,与 bpy 无关;反之亦然。
- 几何库可以被单测 。
Part.open_edges()、Part.bounds()这些方法返回的是数字,可以直接断言。
代价是几何层不能调用 bpy 的便捷功能(比如用 bmesh 做布尔运算、用 recalc_face_normals 修朝向)。这个代价是可以接受的------第 4.2 节会说明为什么这里本来就不该用布尔。
4.2 核心原语:双层参数曲面壳
微缩场景里 90% 的"软"形体都是同一类东西:一片有厚度的曲面。屋顶的鳞片是,鳞片上的积雪是,起伏的雪原是,池冰也是。手工算这种几何几乎不可能------你需要两张曲面(内外各一张)加上四条封边,还要保证接缝连续。
于是我写了这个场景里唯一的核心新原语 grid_solid:给它两个参数函数 fn_a(u, v) 和 fn_b(u, v),前者给外表面、后者给内表面,它负责铺点、连面、封边。
python
def grid_solid(fn_a, fn_b, nu, nv, wrap_u=False):
"""双层参数曲面围成的闭合实体 ------ 微缩场景最核心的新原语。
fn_a(u, v) 给外表面点,fn_b(u, v) 给内表面点;两者之间连侧壁封边。
wrap_u=True 时 u 方向首尾相接(不做 u=0/u=1 的封边)。
这是做"鳞片 / 雪盖 / 雪原 / 池冰"的唯一工具 ------ 手工算顶点
做不出带厚度的曲面。
"""
# ---- 第一步:先铺外表面,再铺内表面,两张顶点表拼在一起 ----
nu_pts = nu if wrap_u else nu + 1
verts = []
for i in range(nu_pts):
u = i / float(nu)
for j in range(nv + 1):
verts.append(fn_a(u, j / float(nv)))
n_a = len(verts)
for i in range(nu_pts):
u = i / float(nu)
for j in range(nv + 1):
verts.append(fn_b(u, j / float(nv)))
# 两个索引函数,把 (i, j) 映射到上面那两张顶点表里
def A(i, j):
return i * (nv + 1) + j
def B(i, j):
return n_a + i * (nv + 1) + j
# ---- 第二步:逐格连两面四边形(外表面一片、内表面一片)----
faces = []
last = nu_pts if wrap_u else nu_pts - 1
for i in range(last):
i2 = (i + 1) % nu_pts
for j in range(nv):
faces.append((A(i, j), A(i2, j), A(i2, j + 1), A(i, j + 1)))
faces.append((B(i, j), B(i, j + 1), B(i2, j + 1), B(i2, j)))
# ---- 第三步:封边。v=0 / v=nv 两条必封,u 方向视 wrap_u 而定 ----
# 少封一条边,这个壳就是开口的,离线自检会立刻报出非水密边。
for i in range(last):
i2 = (i + 1) % nu_pts
faces.append((A(i, 0), B(i, 0), B(i2, 0), A(i2, 0)))
faces.append((A(i, nv), A(i2, nv), B(i2, nv), B(i, nv)))
if not wrap_u:
for j in range(nv):
faces.append((A(0, j), A(0, j + 1), B(0, j + 1), B(0, j)))
faces.append((A(nu, j), B(nu, j), B(nu, j + 1), A(nu, j + 1)))
return verts, faces
这段代码的输入是两个参数函数与网格分辨率 nu × nv,输出是可直接交给 mesh.from_pydata 的顶点表与索引面表。处理过程分三步:先把外表面 fn_a 的 (nu+1)×(nv+1) 个点按行铺进顶点表,再紧接着铺内表面 fn_b 的点(所以内表面的索引要整体偏移 n_a,这就是 A() 与 B() 两个索引函数存在的全部理由);然后逐格连四边形,外表面与内表面各连一遍;最后把四条边界缝合,其中 v=0 与 v=nv 两条无论如何都要封,u 方向只在 wrap_u=False 时才封。
预期结果是得到一个水密闭合实体 :每条边都恰好被两个面共享。这一点由第 6.1 节的离线自检来确认,而不是靠肉眼。这里刻意不去管面的绕序(朝向),因为 Blender 侧会统一做一次 recalc_face_normals,而水密性检查用的是无向边,天然对绕序免疫------把两件独立的事拆开处理,能显著减少调试面。
顺带说明为什么这里不用布尔运算做"减去一层"的效果。布尔在这个尺度上会引入三类问题:薄壁(壁厚 1mm 以下时容差开始打架)、共面(两张面重合导致 Z-fighting)、法线翻转。而双层参数曲面的接缝天然连续、不需要容差,顶点数还可预测------这三个性质在毫米尺度下比"操作直观"重要得多。
4.3 几何库的分层
grid_solid 不是孤立的,它站在一组更基础的原语之上。把它们的关系画出来,会发现整个库其实只有三层。
#mermaid-svg-ektaVNiiaQ3h2wsb{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-ektaVNiiaQ3h2wsb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ektaVNiiaQ3h2wsb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ektaVNiiaQ3h2wsb .error-icon{fill:#552222;}#mermaid-svg-ektaVNiiaQ3h2wsb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ektaVNiiaQ3h2wsb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ektaVNiiaQ3h2wsb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ektaVNiiaQ3h2wsb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ektaVNiiaQ3h2wsb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ektaVNiiaQ3h2wsb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ektaVNiiaQ3h2wsb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ektaVNiiaQ3h2wsb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ektaVNiiaQ3h2wsb .marker.cross{stroke:#333333;}#mermaid-svg-ektaVNiiaQ3h2wsb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ektaVNiiaQ3h2wsb p{margin:0;}#mermaid-svg-ektaVNiiaQ3h2wsb g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-ektaVNiiaQ3h2wsb g.classGroup text .title{font-weight:bolder;}#mermaid-svg-ektaVNiiaQ3h2wsb .cluster-label text{fill:#333;}#mermaid-svg-ektaVNiiaQ3h2wsb .cluster-label span{color:#333;}#mermaid-svg-ektaVNiiaQ3h2wsb .cluster-label span p{background-color:transparent;}#mermaid-svg-ektaVNiiaQ3h2wsb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ektaVNiiaQ3h2wsb .cluster text{fill:#333;}#mermaid-svg-ektaVNiiaQ3h2wsb .cluster span{color:#333;}#mermaid-svg-ektaVNiiaQ3h2wsb .nodeLabel,#mermaid-svg-ektaVNiiaQ3h2wsb .edgeLabel{color:#131300;}#mermaid-svg-ektaVNiiaQ3h2wsb .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-ektaVNiiaQ3h2wsb .label text{fill:#131300;}#mermaid-svg-ektaVNiiaQ3h2wsb .labelBkg{background:#ECECFF;}#mermaid-svg-ektaVNiiaQ3h2wsb .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-ektaVNiiaQ3h2wsb .classTitle{font-weight:bolder;}#mermaid-svg-ektaVNiiaQ3h2wsb .node rect,#mermaid-svg-ektaVNiiaQ3h2wsb .node circle,#mermaid-svg-ektaVNiiaQ3h2wsb .node ellipse,#mermaid-svg-ektaVNiiaQ3h2wsb .node polygon,#mermaid-svg-ektaVNiiaQ3h2wsb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ektaVNiiaQ3h2wsb .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb g.clickable{cursor:pointer;}#mermaid-svg-ektaVNiiaQ3h2wsb g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-ektaVNiiaQ3h2wsb g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-ektaVNiiaQ3h2wsb .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-ektaVNiiaQ3h2wsb .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-ektaVNiiaQ3h2wsb .dashed-line{stroke-dasharray:3;}#mermaid-svg-ektaVNiiaQ3h2wsb .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-ektaVNiiaQ3h2wsb #compositionStart,#mermaid-svg-ektaVNiiaQ3h2wsb .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #compositionEnd,#mermaid-svg-ektaVNiiaQ3h2wsb .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #dependencyStart,#mermaid-svg-ektaVNiiaQ3h2wsb .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #dependencyStart,#mermaid-svg-ektaVNiiaQ3h2wsb .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #extensionStart,#mermaid-svg-ektaVNiiaQ3h2wsb .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #extensionEnd,#mermaid-svg-ektaVNiiaQ3h2wsb .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #aggregationStart,#mermaid-svg-ektaVNiiaQ3h2wsb .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #aggregationEnd,#mermaid-svg-ektaVNiiaQ3h2wsb .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #lollipopStart,#mermaid-svg-ektaVNiiaQ3h2wsb .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb #lollipopEnd,#mermaid-svg-ektaVNiiaQ3h2wsb .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-ektaVNiiaQ3h2wsb .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-ektaVNiiaQ3h2wsb .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ektaVNiiaQ3h2wsb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ektaVNiiaQ3h2wsb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ektaVNiiaQ3h2wsb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} grid_solid 可复用 loft 的连面逻辑
dground / dhouse / dfit / dtree / dyard
独立小件先建在原点再摆位
每个构件是一个 Part
Part
+verts : list
+faces : list
+mat : str
+open_edges() : int
+degenerate_faces() : int
+solids() : int
+bounds() : tuple
基础原语
loft(rings) : 管体
revolve(prof) : 回转体
rounded_box(w,d,h) : 圆角盒
rrect(w,d,r,ne) : 圆角矩形点列
复合原语
grid_solid(fn_a,fn_b) : 双层曲面壳
sweep_round(path) : 圆截面扫掠
cyl_between(p0,p1) : 两点圆柱
catenary(p0,p1) : 悬链线
放置工具
place_yaw(deg,x,y,z)
place_radial(r,deg,z)
mirror_x()
dist_to_polyline()
构件层
图 5:几何库三层结构。Part 的四个体检方法在第 6.1 节被反复调用
五、三次隐形失效的定位实录
这一节是全文的主体。三次故障的画面表现如下,它们的共同点是一眼看上去"像是没渲出来",但代码、日志、构件计数全部正常。

图 6:三次隐形失效的画面对照。A 全空白、B 主体仅占 44%、C 雪面平板且冰塘消失
5.1 失效一:整幅画面只剩背景色
现象:第一、第二版预览渲染出来是纯背景色渐变------没有模型、没有阴影、什么也没有。
日志的自报状态 :包围盒 x -160..160 y -160..160 z -20..168,物体数 22,覆盖率 75.9%。这些数字没有一个是异常的------如果只看日志,我会认为渲染是成功的。
按第 2.4 节的纪律,我不改任何参数,先造一个观测。写了一个场景射线探针脚本,它不依赖渲染结果,直接问 Blender 三个问题:相机在哪、几何有哪些、从相机往画面中心发一条射线会命中谁。
python
"""场景射线探针:不问渲染结果,直接问 Blender"画面中心有什么"。"""
import bpy
from mathutils import Vector
scene = bpy.context.scene
cam = scene.camera
cd = cam.data
# ---- 第一步:把相机的裁剪面与镜头参数打出来 ----
# clip_end 默认 1000,在毫米尺度下这是个危险的默认值。
print("相机位置 :", tuple(round(v, 1) for v in cam.matrix_world.translation))
print("clip_start / clip_end : %.1f / %.1f" % (cd.clip_start, cd.clip_end))
print("镜头 : %.1fmm sensor_width : %.1f sensor_fit : %s"
% (cd.lens, cd.sensor_width, cd.sensor_fit))
# ---- 第二步:列出所有网格物体与面数,确认几何真的建出来了 ----
total = 0
for ob in sorted(scene.objects, key=lambda o: o.name):
if ob.type != 'MESH':
continue
total += len(ob.data.polygons)
print(" %-16s 面 %6d" % (ob.name, len(ob.data.polygons)))
print("网格物体合计面数 :", total)
# ---- 第三步:从相机往画面中心发一条射线,看命中谁、距离多远 ----
# ★ ray_cast 不受 clip_start / clip_end 影响 ------ 这正是它的价值:
# 它能区分"几何不存在"与"几何存在但被裁剪面切掉了"。这两者的
# 画面表现一模一样(都是空白),但修法完全不同。
origin = cam.matrix_world.translation
direction = -(cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, 1.0)))
hit, loc, normal, index, ob, matrix = scene.ray_cast(
scene.view_layers[0].depsgraph, origin, direction)
if hit:
dist = (loc - origin).length
print("画面中心命中 : %s 距离 %.1f" % (ob.name, dist))
print(" => 几何存在。若画面空白,问题在裁剪面或色彩管理")
else:
print("画面中心未命中任何物体")
print(" => 几何可能真的没建出来,或相机根本没对着它")
这段脚本的输入是当前场景,输出是三个可判读的结论。第一步打印裁剪面与镜头参数------这是为了把"裁剪面还是相机朝向"这个二选一先摆到台面上;第二步逐个物体数面数并求总和,用来回答"几何到底建出来了没有";第三步是决定性的:从相机位置沿视线方向发一条射线,用 scene.ray_cast 求交。关键在于 ray_cast 完全不理会裁剪面,所以它测的是"几何与相机在空间中的真实关系",而不是"渲染管线看见了什么"。
预期结果有两种,对应两条完全不同的修复路径:命中,说明几何就在画面正中央、距离是一个具体数字,那问题只能出在渲染管线的某个设置上;未命中,说明几何或相机朝向本身有问题。实测输出是:画面中心命中 snow_yard,距离 1341.7。几何一直都在,就在画面正中央,距离 1.34 米(毫米单位下就是 1341.7mm)。
根因 :Camera.clip_end 的默认值是 1000。相机到主体 1341.7,整个模型都在远裁剪面之外,被整块裁掉了。
修法 :显式设 clip_start = 4.0 / clip_end = 80000.0。
⚠️ 这个坑的隐蔽性在于它没有任何中间信号 。裁剪面不产生警告、不产生日志、不改变包围盒、不改变覆盖率------所有"自检指标"都在正常范围内,因为它们是几何指标,而几何确实是对的。这类"指标正常但结果错误"的故障,只能靠一个绕过整条渲染管线的观测来定位。
5.2 失效二:日志写 75.9%,画面上主体只占 44%
现象:第三版终于有画面了,但主体小得可怜------台子只占画面的 44% 左右,四周大片空白。而日志明确写着"覆盖率 75.9%"。
第一次诊断(错的) :我认为原因是"测量代码与居中代码不在同一个循环里"------自动取景先循环调距离,之后才单独做一次居中,于是打印出来的覆盖率是居中之前测量的。这个判断本身没错,我确实把两件事合并进了同一个循环。但合并之后,主体还是 44%。
第二次诊断(对的) :我停止猜测,改为打印取景的上下文------把测量时的分辨率与 sensor_fit 打出来。结果一行日志就暴露了问题:
[取景] 1920x1080 sensor_fit=AUTO sensor_width=36.0
取景跑在设置渲染参数之前 ,此刻场景分辨率还是启动默认的 1920×1080。而 bpy_extras.object_utils.world_to_camera_view 是按当前的 scene.render 设置做投影 的,sensor_fit=AUTO 表示"把 sensor_width 铺在长边上"。于是在 16:9 画幅下:
| 量 | 1920×1080 时 | 600×600 时 |
|---|---|---|
| sensor_width | 36.0 mm | 36.0 mm |
| sensor_height | 36 × 1080/1920 = 20.25 mm | 36.0 mm |
| 竖直视场 | 窄 | 宽 1.78 倍 |
sensor_fit=AUTO 的语义是"按长边铺传感器",所以正方形画幅下两条边都拿到 36mm,而 16:9 画幅下高度只拿到 20.25mm------竖直视场比最终要渲染的方画幅窄了 1.78 倍。自动取景于是收敛在"竖直撑满"这个轴上,等真正改成方画幅渲染时视场一放开,主体立刻缩水。
验证一下数量级:实测取景时的竖直半跨度是 0.488,乘 1.778 得 0.868,与日志里的 0.90 吻合。这就是"日志 90%、画面 55%"的全部原因。
修法 :把 setup_render() 挪到 setup_camera() 之前 ;同时在取景函数开头打印分辨率与 sensor_fit,让这个上下文永远可见。
下面这张时序图是修正后的取景循环------距离与居中共用同一个循环,所以每轮都用当前相机重新量一次跨度,最后打印出来的覆盖率必然等于验收值。

-->
world_to_camera_view setup_camera() setup_render() main() world_to_camera_view setup_camera() setup_render() main() #mermaid-svg-xX1McPWSdDynstEC{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-xX1McPWSdDynstEC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xX1McPWSdDynstEC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xX1McPWSdDynstEC .error-icon{fill:#552222;}#mermaid-svg-xX1McPWSdDynstEC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xX1McPWSdDynstEC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xX1McPWSdDynstEC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xX1McPWSdDynstEC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xX1McPWSdDynstEC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xX1McPWSdDynstEC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xX1McPWSdDynstEC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xX1McPWSdDynstEC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xX1McPWSdDynstEC .marker.cross{stroke:#333333;}#mermaid-svg-xX1McPWSdDynstEC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xX1McPWSdDynstEC p{margin:0;}#mermaid-svg-xX1McPWSdDynstEC .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xX1McPWSdDynstEC text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-xX1McPWSdDynstEC .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-xX1McPWSdDynstEC .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-xX1McPWSdDynstEC .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-xX1McPWSdDynstEC .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-xX1McPWSdDynstEC #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-xX1McPWSdDynstEC .sequenceNumber{fill:white;}#mermaid-svg-xX1McPWSdDynstEC #sequencenumber{fill:#333;}#mermaid-svg-xX1McPWSdDynstEC #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-xX1McPWSdDynstEC .messageText{fill:#333;stroke:none;}#mermaid-svg-xX1McPWSdDynstEC .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xX1McPWSdDynstEC .labelText,#mermaid-svg-xX1McPWSdDynstEC .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-xX1McPWSdDynstEC .loopText,#mermaid-svg-xX1McPWSdDynstEC .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-xX1McPWSdDynstEC .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-xX1McPWSdDynstEC .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-xX1McPWSdDynstEC .noteText,#mermaid-svg-xX1McPWSdDynstEC .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-xX1McPWSdDynstEC .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xX1McPWSdDynstEC .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xX1McPWSdDynstEC .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-xX1McPWSdDynstEC .actorPopupMenu{position:absolute;}#mermaid-svg-xX1McPWSdDynstEC .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-xX1McPWSdDynstEC .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-xX1McPWSdDynstEC .actor-man circle,#mermaid-svg-xX1McPWSdDynstEC line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-xX1McPWSdDynstEC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 必须在前!投影依赖当前 render 设置 loop 最多 40 轮 先设分辨率/采样/sensor_fit 传入主体中心与目标覆盖率 用当前相机投影所有顶点 返回 (u, v) 跨度与投影中心 覆盖率达标 且 中心偏差 < 0.0015 ? 否则整台相机平移 + 距离乘 cov/COVER 打印覆盖率(此时必等于验收值)
图 7:自动取景的收敛时序。取景必须在设置渲染参数之后,否则投影用的是错的画幅
⚠️ 这条经验的适用范围远不止 Blender:任何"测量依赖全局配置"的代码,都要保证配置先于测量被设置。 而它的典型症状正是"日志与观感不符"------如果你发现日志说的和眼睛看到的对不上,那八成是测量时用的配置与最终生效的配置不是同一份。
5.3 失效三:整块消失的冰塘
现象 :院子里应该有一片冰塘(半径 38mm,占台面相当大的面积),但在渲染图上完全看不到。
这是三次里最难的一次,因为画面本身看起来是"合理的"------雪原上有个不明显的凹陷,没有冰。我先后推翻了两个假设:
- 假设一:冰面的白霜占比太高,把暗色冰盖住了。 把霜占比从 0.60 降到 0.34,重渲------没有变化。
- 假设二:坐标映射推错了,冰塘其实建在画面外。 为此我写了局部裁剪放大工具,把台面切成 9 块逐一放大找------没有。
我推翻假设二用的工具值得一提:材质 ID 调试图 。它的思路是给每个构件分一种纯色(雪是白、石是灰、冰是青、木是棕),渲染出来就能一眼看出"哪个构件在哪"。这张图在这两轮里给我的答案是:连调试图上都找不到青色。
这就是决定性的一步。如果冰被雪盖住了,调试图上应该能看到一片青色被白色包围;而现在青色根本不存在,说明问题不在"被遮挡",而在"它压根不在可见的位置上"。
根因 :雪原是一块用 grid_solid 生成的实心板 。我原本在雪面高度场里为冰塘"挖了一个浅坑"------但高度场只决定雪面在每一点的高度,在一块实心板上降低局部高度并不会开出一个洞。于是冰板被整块埋在雪里,它的顶面还低于雪面。连材质调试图都找不到它,是因为它确实被雪完全包住了。
修法分两步:加一个"冰塘范围掩码",并引入一个共享的高度常量。
python
# ---- 冰面高度:由"塘心处的正常雪面"往下沉 pond_depth 定出来 ----
# ★ ICE_TOP 是模块级常量,也是唯一真相来源:snow_z 用它压塘底、
# build_pond 用它放冰板,两边共用这一个数,参数怎么调都不会错位。
ICE_TOP = _snow_base(P["pond_c"][0], P["pond_c"][1],
snow_env(*P["pond_c"])) - P["pond_depth"]
def pond_mask(px, py):
"""冰塘范围掩码:塘内 1、塘外 0,中间约 13mm 过渡。
★ 为什么不用高斯:冰板半径是 38mm,掩码必须在**整个冰板范围内
都等于 1**,否则冰会从边缘开始被雪盖住,看起来只是"变小了"。
"到圆心的距离 + smoothstep"能精确控制这个平台区,高斯不能。
"""
dq = math.hypot(px - P["pond_c"][0], py - P["pond_c"][1])
return 1.0 - G.smoothstep((dq - P["pond_r"] * 0.98) / (P["pond_r"] * 0.34))
def snow_z(px, py, env=None):
"""雪面高度场。**所有贴地物件都必须调它**,否则会悬空或陷进雪里。
★ 冰塘这一项不是"洼地",而是"把塘内的雪面压到冰面以下"。
雪原是实心的板,只在高度场里挖浅坑并不会露出冰 ------
冰板会整块埋在雪里,连材质 ID 调试图上都找不到它。
"""
if env is None:
env = snow_env(px, py)
m = pond_mask(px, py)
# 起伏噪声:两段不同特征尺寸,塘内压到 18% 让它变光滑
und = (P["snow_und"] * env * G.fbm_s(px / 38.0, py / 38.0, 3, 11)
+ 0.85 * env * G.fbm_s(px / 11.0, py / 11.0, 2, 23))
und *= G.lerp(1.0, 0.18, m)
z = G.lerp(0.55, P["snow_h"], env) + und
# 屋基周围的雪堆环:离屋基 63mm 处最高(高斯环)
dh = math.hypot(px - P["hx"], py - P["hy"])
z += 2.45 * math.exp(-((dh - 63.0) / 26.0) ** 2)
# 小径被踩实:沿折线做负向高斯
dp = U.dist_to_polyline(px, py, P["path_pts"])
z -= 2.10 * math.exp(-(dp / 13.5) ** 2)
# ★ 最后一步才是"压塘":掩码为 1 的地方直接拉到冰面以下 1.05mm
if m > 0.002:
z = G.lerp(z, ICE_TOP - 1.05, m)
return z
这段代码解决的是同一类问题:如何在一张实心地形上"开"出一个能被看见的凹陷 。输入是平面上任意一点 (px, py),输出是该点的雪面高度。处理顺序有讲究:先算起伏噪声(并乘上 lerp(1.0, 0.18, m),让塘内的雪面变光滑------这是"结冰的水面是平的"这个视觉线索),再加雪堆环与小径的踩实凹陷,最后一步才做压塘。
最后一步的写法是整段的核心:z = lerp(z, ICE_TOP - 1.05, m)。它不是在原来高度上减一个数,而是直接把塘内的雪面拉到一个固定高度之下 。ICE_TOP 是冰板顶面的高度,减 1.05mm 保证雪面明确低于冰面,于是冰板必然露出。预期结果是:掩码等于 1 的整个圆内,雪面高度都是 ICE_TOP - 1.05,冰板清晰地浮在雪原之中。
这里有一条值得单独记住的推论:凡是"两块曲面在某个边界相接"的地方,两侧必须共用同一个高度函数。 这个场景里我踩了三次同一个坑------鳞片与鳞片上积雪、雪原本体与所有贴地构件、冰面与冰缘的雪唇。第三次的修法是把雪唇外缘写成 snow_z(px, py) 而不是 top + 常数,否则冰缘外会留一圈台阶或一条缝,在图上表现为一道没有来由的暗弧。
三次失效的完整对照如下。注意"自报状态"那一列------它解释了为什么这三次都难。
| 失效 | 画面现象 | 代码/日志自报状态 | 决定性观测 | 根因 | 修法 |
|---|---|---|---|---|---|
| 一 · 全空白 | 纯背景色,无任何几何 | 包围盒正常、22 个物体、覆盖率 75.9% | ray_cast 命中雪面,距离 1341.7 |
clip_end 默认 1000,模型全在裁剪面外 |
clip_start=4.0 / clip_end=80000.0 |
| 二 · 主体缩水 | 主体只占 44%,四周大片空白 | 日志覆盖率 75.9%,取景循环"已收敛" | 打印分辨率与 sensor_fit |
取景跑在 setup_render 之前,投影用的是 1920×1080 |
调换调用顺序;取景前打印画幅上下文 |
| 三 · 冰塘消失 | 雪原上看不到冰,连调试图都没有青色 | 顶点/面数正常、掩码函数返回 1.0 | 材质 ID 调试图里找不到青色 | 实心板上降低高度不会开洞,冰板被整块埋住 | pond_mask 平台区 + ICE_TOP 共享常量 |
表里最能说明问题的是第三列:三次的"自报状态"都是正常的。这就是隐形失效的定义------故障不在状态里,只在结果里。
六、验证:怎么把"我觉得"变成"我量过"
三次失效能被定位,靠的不是更仔细地看图,而是把判断依据从视觉换成数字 。这一节把用到的三种验证手段集中说清楚,它们的共同点是:都比渲染一次便宜得多。
6.1 离线几何自检:2 秒跑完 10 万面
当模型有 103,820 个面时,人眼在渲染图上永远无法判断"有没有退化面""是不是水密的"------这两类问题在画面上要么完全看不见,要么表现为一处莫名其妙的黑斑,你根本不会往几何上想。而代码可以在 2 秒内数清楚。
因为几何库不依赖 bpy(第 4.1 节),这个自检可以用普通 Python 直接跑:
python
"""离线几何自检:不依赖 bpy,2 秒跑完 10 万面。"""
import math
class Part:
"""一个构件:顶点表 + 面表 + 材质名。"""
def open_edges(self):
"""返回非水密边数。水密实体的每条边必须恰好被 2 个面共享。
★ 用**无向边**统计:面的绕序(朝向)不影响结果。
这样"连面"与"修法线"就是两件独立的事,调试面小很多。
"""
cnt = {}
for f in self.faces:
n = len(f)
for k in range(n):
a, b = f[k], f[(k + 1) % n]
key = (a, b) if a < b else (b, a)
cnt[key] = cnt.get(key, 0) + 1
return sum(1 for v in cnt.values() if v != 2)
def degenerate_faces(self, eps=1e-7):
"""返回退化面数(真实面积近似为 0 的面)。
★ 不能用"三个顶点是否重合"来判断:退化面经常是
**顶点各不相同、但共线或近乎共线**,此时叉积模长趋零。
改用 Newell 法先求出多边形法向量的模长 ------ 它正比于
该多边形的真实面积,与顶点是否重合无关。
"""
bad = 0
for f in self.faces:
nx = ny = nz = 0.0
for k in range(len(f)):
a = self.verts[f[k]]
b = self.verts[f[(k + 1) % len(f)]]
nx += (a[1] - b[1]) * (a[2] + b[2])
ny += (a[2] - b[2]) * (a[0] + b[0])
nz += (a[0] - b[0]) * (a[1] + b[1])
if 0.5 * math.sqrt(nx * nx + ny * ny + nz * nz) < eps:
bad += 1
return bad
这段代码给 Part 加了两个"体检方法",输入是构件自身的顶点表与面表,输出是两个整数------非水密边数与退化面数。open_edges 的做法是统计每条边的使用次数:把每个面的相邻顶点对取出来、统一成 (小, 大) 的无向形式、计数,最后数出使用次数不等于 2 的边。用无向边是关键,它让这个检查对绕序完全免疫,于是我不必在生成几何时纠结面的朝向。degenerate_faces 则用 Newell 法算多边形法向量,其模长正比于真实面积,面积趋零就判定退化------这比检查顶点是否重合可靠得多。
预期结果是两个数都是 0。实测这个自检在本项目立刻抓到了一个真实缺陷 :脚印的碟形凹坑有 360 个退化面。根因是碟内环的半径写成 rr = v,当 v = 0 时那一圈的 18 个顶点全部塌到同一个点上------这是模型里真实存在的坏面,但它渲染出来只是一处颜色略深的斑点,靠眼睛根本不可能发现。
6.2 局部裁剪放大:判断材质细节的唯一便宜办法
渲染一张预览要 53 秒,而判断"冰塘到底读成冰还是读成一个洞"这种问题,必须看到像素级细节。为此写了个裁剪放大工具:把渲染图的任意矩形区域裁出来、最近邻放大,2 秒出结果。这个工具的价值在于它把"改一个参数、渲一次、猜一次"的循环,变成了"看清了再改"。
实现上有一个关键取舍:它用 Blender 自带的 numpy ,而不是往环境里装 PIL。这样做的代价是不能用 PIL 的便捷 API,但收益是零依赖 ------整个项目从头到尾没有安装过任何第三方包。两个必须注意的实现细节:先声明 image.colorspace_settings.name = 'Non-Color' 再读像素(等价于直接读文件里的原始字节,来回不会串色);以及 image.pixels 的第 0 行是图像的底部,要翻一次才能对上直觉。
6.3 像素级一致性验证:别用整文件 MD5
给脚本做了一处修改之后,我重渲了一次正片,想确认"画面到底有没有变"。直觉做法是比两个文件的 MD5------这个做法会给出错误的结论。
Blender 会往 PNG 里写一批 tEXt 元数据:渲染日期时间、RenderTime、cycles.ViewLayer.total_time、synchronization_time。这些字符串每次运行都不同,所以整文件 MD5 必然不同,你会误判成"画面变了"。
正确的做法是只比压缩后的像素数据,也就是 IDAT 块。用 struct 顺序解析 PNG 的块结构、把同名块的内容拼起来比:
| 比较方式 | 结果 | 能不能说明问题 |
|---|---|---|
| 整文件 MD5 | 不同 | ❌ 元数据里有渲染时间戳,必然不同 |
| 文件字节数 | 完全相同(1,295,905) | ⚠️ 巧合,不能单独作证据 |
| 逐字节差异 | 只有 40 字节不同 | ✅ 且全部落在元数据区 |
IDAT 块内容 |
逐字节相同 | ✅ 像素级一致 |
四条证据串起来给出了确定的结论:画面像素级一致,改动确实是 no-op。顺带还得到一个有用的性质------本场景的 Cycles 渲染是确定性的:144 采样配合自适应阈值,两次运行的噪声完全相同。这意味着"小改一处、只需重渲确认画面没变"这条验证路径是可信的,不必担心每次渲染都引入随机差异。
6.4 渲染预算:为什么迭代一律走预览档
下面这张表是整个迭代过程的时间账。它解释了一个看起来奇怪的事实:12 轮迭代 + 3 次正片,总渲染时间却不到两小时。
| 档位 | 分辨率 | 采样 | 自适应阈值 | 耗时 | 用途 |
|---|---|---|---|---|---|
| 预览 | 600×600 | 48 | 0.010 | 53.2 秒 | 全部 12 轮迭代 |
| 正片 | 1200×1200 | 144 | 0.004 | 481.5 秒 | 仅最终定稿 1 次 |
两个档位的比例可以验证:像素数 ×4、采样数 ×3,理论倍率 12 倍;实测 481.5 / 53.2 = 9.05 倍 。不到 12 倍的原因是几何构建与同步有固定开销(约 5 秒),这部分不随分辨率增长。知道这个比例的实际意义是:改一个参数想看效果,选预览档,一分钟就能得到答案;选正片档,八分钟才能得到同一个答案。
七、最花心思的一处细节:螺旋鳞片屋顶
任务要求"小屋造型要有创意、避免大众化"。最后落定的方案是把冷杉球果的螺旋鳞片变成屋顶------一座球果形状的圆屋。这个造型花了最多的心思,但它不是靠建模技巧堆出来的,而是靠三个明确的决定:
- 层数由周长决定,不是固定值。 锥面从底部到顶部半径从 47mm 收到 3mm,如果每层都用同样的鳞片数,顶部会密到糊成一团。所以每层的片数按该层的周长算。
- 逐层相位错开半格。 这是螺旋纹的来源。如果各层严格对齐,铺出来的是"一圈一圈的环";错开半格之后,鳞片的接缝连成一条斜线,才会读成"螺旋"。
- 鳞片与鳞片上的积雪共用同一个基底参数曲面。 雪片的 u/v 范围比鳞片略收,收出来的边正好露出一圈鳞片本色------积雪的边界于是天然贴合鳞片,不需要额外对齐。
| 参数 | 取值 | 说明 |
|---|---|---|
| 锥体高度 | 99 mm(z 49 → 148) | 占房屋总高的 2/3,视觉主体 |
| 底部 / 顶部半径 | 47 mm / 3 mm | 收得比等角锥更急,更像球果 |
| 鳞片层数 | 9 层 | 再多则顶部鳞片小于 2mm,渲染不清 |
| 鳞片总数 | 154 片 | 由各层周长累加得出 |
| 单片长度倍率 | 1.42 | 让相邻层的鳞片互相交叠,遮住接缝 |
| 单片抬起量 | 2.2 mm | 鳞片顶端离锥面抬起的距离,形成"翘边" |
最后一行值得展开一句:"翘边"是这类造型是否成立的关键。 鳞片如果紧贴锥面,即使层数、相位、大小都对,渲出来也只是一个带纹路的锥体;抬起 2.2mm 之后,每片顶端与锥面之间出现一道真正的缝隙,暖光斜射时缝里会有阴影、边缘会有一道高光------这层"厚度感"才是它读起来像球果而不是像圆锥的原因。
八、适用边界与风险提示
8.1 什么时候该用这条路,什么时候不该
程序化建模不是万能解,它在一个特定的场景区间里优势明显,出了这个区间就会变成负担。
| 场景 | 建议 | 理由 |
|---|---|---|
| 参数化、可复现的场景(微缩景观、建筑构件、工业零件) | ✅ 推荐 | 参数集中、改一处重生成全部,迭代成本极低 |
| 需要批量生成变体(不同尺寸 / 配色 / 季节) | ✅ 推荐 | 改参数文件即可,无需重新建模 |
| 有机造型、雕刻类(角色、生物、布料褶皱) | ❌ 不推荐 | 参数曲面难以表达,手工雕刻或雕刻软件更合适 |
| 需要精确匹配参考图的一次性模型 | ⚠️ 谨慎 | 程序化调节到"像某张图"的效率低于直接建模 |
| 硬性要求实时预览(每秒多次) | ❌ 不推荐 | 无独显时单帧 8 分钟,完全不满足交互需求 |
8.2 三个具体风险
⚠️ 版本风险(最高)。本文涉及的 API 变更集中在三处,都是"不报错但行为变了"的类型:渲染引擎的可用性枚举(第 3.3 节)、合成器节点树的重构、废弃属性的读写行为。如果你的 Blender 是 4.x,第 3.3 节的引擎选择逻辑仍然成立,但合成器部分需要改写。
⚠️ 时间风险 。正片一帧 481.5 秒,这意味着每次"想看看改完什么样"的代价是 8 分钟。这直接改变了工作方式:不能靠频繁渲染来试探,必须先做能离线验证的判断(第 6.1 节),再用预览档收敛,最后才跑正片。如果跳过这个纪律、每改一行就渲一次正片,一个下午只能试几十次。
⚠️ 覆盖风险(回滚) 。主脚本每次运行都会从零重建整个场景 并覆盖 output/ 下的同名文件,这是幂等设计的好处(不会污染既有场景),但代价是上次的产物会被直接覆盖 。所以我在重渲正片之前先做了一次显式备份,比对确认无异后才保留新版。如果你的流程里 blend 文件承载了手工调整的部分,不要直接跑主脚本。
8.3 一套可迁移的排查纪律
把三次定位过程抽出来,得到的是三步,与具体工具无关:
- 先确认"东西在不在",再确认"看不看得见"。 用一条绕过渲染管线的观测(射线、包围盒、物体计数)把这两个问题分开。第 5.1 节的
ray_cast就是这个动作,它把"渲染空白"这个模糊现象一刀切成"几何在,但被裁掉了"。 - 打印测量时的上下文,不打印结论。 第 5.2 节的转折点不是打印覆盖率,而是打印分辨率与
sensor_fit------结论(75.9%)本身是对的,错的是测量时使用的配置。当结论与观感矛盾时,可疑的是上下文而不是结论。 - 用隔离调试图排除"被遮挡"这个可能。 第 5.3 节的材质 ID 调试图只回答一个问题:这个构件到底有没有被渲出来。它一旦给出"没有",就可以彻底排除所有"被别的构件挡住"的假设------这一刀砍掉了我前面两个错误的假设方向。

九、总结
回到开头那句任务:建一个冬日微缩展示台,底座上有一间有创意的小屋和它的院落,要让人眼前一亮。12 轮之后这件事做完了------一座球果屋顶的圆屋,浮在圆角雪台上,冰塘、灯串、柴堆、脚印各在其位,108,728 个顶点全部由代码算出,零贴图、零外部资源。
但真正值得带走的不是这座模型,而是三次隐形失效留下的形状。它们的共同点是:代码没报错、日志自报正常、中间产物的各项指标全部落在合理范围,而画面是错的。 这类故障不可能靠"更仔细地看图"解决,只能靠一个绕过整条渲染管线的观测来定位------因为渲染管线的每一层都可能在你不知不觉中改动一件事。
如果只记一条,我会记这条纪律:当渲染结果与代码逻辑矛盾时,不要改参数,先造一个能证伪的观测。 改参数是在无限的猜测空间里搜索,而造观测是把问题压到一个数字或一个布尔值上,让它自己回答。三次里我推翻了 7 个假设,每一次弯路都发生在"先改参数"上,每一次突破都发生在"先造观测"上。
两个留给你的问题:你手上有没有一类"测试通过了但结果不对"的问题? 如果有,它是否也能被压成一个数字------就像"命中,距离 1341.7"那样?以及你的验证手段里,有多少是真正绕开了被测系统的? 如果验证跑的路径和产品跑的路径是同一个,那它们可能一起错。
参考资料
- Blender 官方 Python API 文档:https://docs.blender.org/api/current/
bpy_extras.object_utils.world_to_camera_view说明(投影依赖当前渲染设置):https://docs.blender.org/api/current/bpy_extras.object_utils.html- Cycles 渲染设置与采样参数:https://docs.blender.org/manual/en/latest/render/cycles/render_settings/sampling.html
- 合成器节点(5.x 起改为节点组挂载):https://docs.blender.org/manual/en/latest/compositing/index.html
- 本文全部脚本、渲染图与 blend 文件:
diorama-winter/(17 个 Python 文件,4,133 行)