Blender-2-49-脚本编程-全-
Blender 2.49 脚本编程(全)
原文:
zh.annas-archive.org/md5/029ee8dcf5ba94907a37ecd90be24351译者:飞龙
前言
Blender 无疑是功能最强大、最灵活的开源 3D 软件包。其功能几乎可以与许多专业软件包相媲美,甚至超越。Blender 内置的 Python 解释器在挖掘这种功能方面发挥着重要作用,并允许艺术家进一步扩展其功能。然而,掌握脚本语言并熟悉 Blender 通过其 Python API 提供的众多可能性可能是一项艰巨的任务。
本书将通过展示许多现实问题的实际解决方案,展示如何充分利用 Blender。每个示例都是一个完整的可工作脚本,以非常详细的方式逐步解释。
本书涵盖的内容
第一章, 使用 Python 扩展 Blender,为你概述了在 Blender 中使用 Python 可以和不能完成的事情。它教你如何安装完整的 Python 发行版以及如何使用内置的编辑器。你还将学习如何编写和运行一个简单的 Python 脚本,以及如何将其集成到 Blender 的菜单系统中。
第二章, 创建和编辑 对象,介绍了对象和网格,你将了解如何通过编程方式操作它们。具体来说,你将学习如何创建可配置的网格对象,设计图形用户界面,以及如何使脚本存储用户选择以便以后重用。你还将学习如何在网格中选择顶点和面,将一个对象作为父对象附加到另一个对象上,以及如何创建组。最后,本章展示了如何从命令行运行 Blender,在后台渲染,以及如何处理命令行参数。
第三章, 顶点组和材质,讲述了顶点组的多种用途以及它们的灵活性。你将了解如何定义顶点组以及如何将顶点分配到顶点组中。你还将学习如何使用这些顶点组进行修改器和骨架。你还将探讨将不同材质应用于不同面的应用,以及如何将顶点颜色分配给顶点。
第四章, Pydrivers 和约束,展示了如何将内置约束与 Blender 对象关联,以及如何通过所谓的pydrivers使用它们来定义动画属性之间的复杂关系。你还将定义新的复杂约束,这些约束可以像内置约束一样使用。具体来说,你将了解如何通过 Python 表达式驱动一个IPO,如何绕过pydrivers固有的某些限制,以及如何通过添加约束来限制对象和骨骼的运动。本章教你如何编写一个 Python 约束,使对象吸附到另一个对象上最近的顶点上。
第五章,对帧变化的响应,专注于编写可能用于响应某些事件的脚本。你可以了解脚本链接和空间处理器的概念,以及它们如何用于在动画的每个帧变化时执行活动。你还将看到如何将附加信息与对象关联,如何使用脚本链接通过改变其布局或改变其透明度来使对象出现或消失,以及如何实现一个方案,在每个帧上为对象关联不同的网格。最后,你可以了解如何增强 3D 视图的功能。
第六章,形状键、IPOs 和姿态,发现 IPOs 在动画场景中还有更多用途。尽管 IPOs 在第四章(第四章。Pydrivers 和约束)中介绍,但在这里你将学习如何为所有类型的对象定义 IPOs,将形状键与网格关联,以及如何为这些形状键定义 IPOs。你还将了解如何摆姿势骨架,以及如何将姿态组合成动作。
第七章,使用 Pynodes 创建自定义着色器和纹理,介绍了 Pynodes,并让你了解它们如何使你能够定义全新的纹理和材质。你将学习如何编写创建简单颜色模式的 Pynodes,产生具有法线模式的 Pynodes,以及如何动画 Pynodes。本章还解释了产生高度和坡度相关材质的 Pynodes,甚至创建对入射光角度做出反应的着色器。
第八章,渲染 和图像处理,转向整个渲染过程。你可以自动化这个渲染过程,以各种方式组合生成的图像,甚至将 Blender 变成一个专门的 Web 服务器。具体来说,你将学习如何自动化渲染过程,为产品展示创建多个视图,以及从复杂对象创建广告牌。你还将了解如何通过一些外部库增强 Blender 以处理图像,包括渲染结果。
第九章,扩展工具集,更多地是关于通过扩展其功能来简化 Blender 的日常使用,而不是渲染。在本章中,你将学习如何列出和归档资产,如图像贴图,使用 FTP 自动发布渲染图像,通过正则表达式搜索扩展内置编辑器的功能,使用 Psyco——一个即时编译器来加速计算,以及使用 Subversion 为你的脚本添加版本控制。
附录 A,链接和资源,为你提供了本书中使用的多数资源的列表,以及一些一般有用的信息。
附录 B, 常见错误,突出了比其他问题更频繁出现的一些常见问题,以及一些错误。
附录 C, 未来发展方向,是最后一篇附录,试图展示未来可能带来什么,以及这如何影响您,因为 Blender 和 Python 都在不断得到进一步的发展。
您需要这本书什么
书中的所有示例都使用 Blender 2.49(可在www.blender.org获取)及其内置的 Python 2.6.x 语言。许多示例假设您已经有一个完整的 Python(www.python.org)发行版。在第一章 使用 Python 扩展 Blender 中,您将学习如何安装一个完整的发行版——如果您还没有的话。Blender 和 Python 是平台无关的,所有示例都应该在 Windows、Linux 和 Mac 上运行得同样好。还使用了某些附加模块,并在适当的地方提供了合适的下载说明。所有示例都可以从出版商的网站(www.packtpub.com)下载。
这本书面向谁
这本书是为那些熟悉 Blender 作为建模和渲染工具的用户,并且希望扩展他们的技能以包括 Blender 脚本来自动化繁琐的任务并实现其他情况下不可能的结果而编写的。Blender 经验是必不可少的,同样,一些 Python 编程经验也是必要的。
术语
在这本书中,您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词将如下显示:“具有网格构建块的 Python 文件名为mymesh.py,因此我们代码的第一部分包含以下import语句。”
代码块设置如下:
def event(evt, val):
if evt == Draw.ESCKEY:
Draw.Exit() # exit when user presses ESC
return
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
def error(text):
Draw.Register(lambda:msg(text), event, button_event)
任何命令行输入或输出都将如下所示:
blender -P /full/path/to/barchart.py
新术语和重要词汇将以粗体显示。例如,您在菜单或对话框中看到的单词,在文本中会像这样显示:“然后我们可以将这个顶点组应用到粒子上下文中的额外面板上的密度参数,以控制发射。”
注意
警告或重要注意事项会以这样的框出现。
小贴士
小技巧和技巧会像这样显示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。
要向我们发送一般反馈,只需发送一封电子邮件到 <feedback@packtpub.com>,并在邮件的主题中提及书名。
如果您需要一本书并且希望我们看到它出版,请通过 www.packtpub.com 上的 建议书名 表格发送给我们,或者发送电子邮件到 <suggest@packtpub.com>。
如果您在某个主题上具有专业知识,并且对撰写或参与关于该主题的书籍感兴趣,请参阅我们关于 www.packtpub.com/authors 的作者指南。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
小贴士
下载本书的示例代码
访问 www.packtpub.com//sites/default/files/downloads/0400_Code.zip 直接下载示例代码。
可下载的文件包含如何使用它们的说明。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问 www.packtpub.com/support,选择您的书籍,点击 让我们知道 链接,并输入您的错误清单的详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,并将错误上传到我们的网站,或添加到该标题的现有错误清单中,在错误清单部分。您可以通过从 www.packtpub.com/support 选择您的标题来查看任何现有错误清单。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面的帮助。
询问
如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章:使用 Python 扩展 Blender
在我们开始用 Blender 编写脚本之前,我们必须检查是否拥有所有必要的工具。之后,我们将必须熟悉这些工具,以便我们可以自信地使用它们。在本章中,我们将探讨以下内容:
-
在 Blender 中使用 Python 可以完成什么和不能完成什么
-
如何安装完整的 Python 发行版
-
如何使用内置编辑器
-
如何运行 Python 脚本
-
如何探索内置模块
-
如何编写一个简单的脚本,将对象添加到 Blender 场景中
-
如何在 Blender 脚本菜单中注册脚本
-
如何以用户友好的方式编写脚本文档
-
如何分发脚本
由于有这么多可能的事情要做,有很多东西要学习,但幸运的是,学习曲线并没有看起来那么陡峭。在我们深入之前,让我们输入几行简单的 Python 代码,将一个简单的对象放入我们的 Blender 场景中,以证明我们能够做到这一点。
-
使用空场景启动 Blender。
![使用 Python 扩展 Blender]()
-
打开交互式 Python 控制台(参考前面的截图以查看位置)。
-
输入以下行(每行以Enter/Return结束)。
mesh = Mesh.Primitives.Monkey() Scene.GetCurrent().objects.new(mesh,'Suzanne') Window.RedrawAll()
哇!这就是添加 Suzanne(Blender 的著名吉祥物)到场景中所需的所有内容。

Blender API
几乎所有 Blender 中的内容都可以通过 Python 脚本访问,但也有一些例外和限制。在本节中,我们将说明这究竟意味着什么,以及哪些显著特性对 Python 不可用(例如,流体动力学)。
Blender API 由三个主要兴趣区域组成:
-
访问 Blender 对象及其属性,例如
Camera对象及其angle属性或Scene对象及其objects属性 -
访问要执行的操作,例如添加一个新的
Camera或渲染一张图像 -
通过使用简单的构建块或与 Blender 事件系统交互来访问图形用户界面
还有一些工具不适合任何这些类别,因为它们涉及到的抽象与最终用户看到的 Blender 对象没有直接关系,例如操作向量和矩阵的函数。
很多的功能
总的来说,这意味着我们可以通过 Python 脚本实现很多事情。我们可以:
-
创建任何类型的 Blender 新对象,包括相机、灯具、网格,甚至场景
-
通过图形用户界面与用户交互
-
自动化 Blender 中的常见任务,如渲染
-
自动化 Blender 之外的任务,如清理目录
-
操作通过 API 暴露的 Blender 对象的任何属性
那最后一个声明显示了 Blender API 当前的一个弱点:开发者添加到 Blender C 源代码中的任何对象属性都必须在 Python API 中单独提供。没有从内部结构到 Python 中可用接口的自动转换,这意味着必须重复工作,可能会导致功能缺失。例如,在 Blender 2.49 中,根本无法从脚本中设置流体模拟。尽管可以设置粒子系统,但无法设置鸟群粒子系统的行为特性。
2.49 Python API 的另一个问题是,用户可能选择在对象上执行的大多数操作在 API 中没有等效功能。设置简单的参数,如相机角度或旋转任何对象都是容易的,甚至将例如,子表面修改器关联到网格也只需要几行代码,但常见的操作,尤其是在网格对象上,如细分选定的边或挤出面,在 API 中缺失,必须由脚本开发者实现。
这些问题促使 Blender 开发者对 2.5 版本的 Blender Python API 进行了全面的重构,重点是功能一致性(也就是说,在 Blender 中可能做到的一切都应该能够通过 Python API 实现)。这意味着在许多情况下,在 Blender 2.5 中获取相同的结果将会容易得多。
最后,Python 不仅仅用于独立脚本:PyDrivers和PyConstraints使我们能够控制 Blender 对象的行为,我们将在后续章节中遇到它们。Python 还允许我们编写自定义纹理和着色器作为节点系统的一部分,正如我们将在第七章中看到的,创建自定义着色器和纹理。
此外,重要的是要记住,Python 为我们提供的不仅仅是(已经令人印象深刻的)自动化 Blender 中各种任务的工具。Python 是一种通用编程语言,包含了一个广泛的工具库,因此我们不必求助于外部工具来执行常见的系统任务,如复制文件或归档(压缩)目录。甚至网络任务也可以相当容易地实现,正如许多渲染农场解决方案所证明的那样。
一些内置功能
当我们安装 Blender 时,Python 解释器已经是应用程序的一部分。这意味着不需要单独安装 Python 作为应用程序。但 Python 不仅仅是解释器。Python 附带了一个庞大的模块集合,提供了丰富的功能。从文件操作到 XML 处理等一切都可以使用,而且最好的是,这些模块是语言的标准部分。它们与 Python 解释器本身一样得到良好的维护,并且(在极少数例外的情况下)在 Python 运行的任何平台上都可用。
当然,这个模块集合相当大(大约 40MB),因此 Blender 的开发者选择只提供最基本的部分,主要是数学模块。如果你想要保持 Blender 下载的大小可管理,这样做是有意义的。许多 Python 开发者已经依赖于标准发行版,因为不必重新发明轮子可以节省大量的时间,更不用说开发并测试一个完整的 XML 库并不是一件容易的事情,比如说,仅仅因为你想要能够读取一个简单的 XML 文件。这就是为什么现在基本上是一个共识,安装完整的 Python 发行版是个好主意。幸运的是,安装过程与 Blender 本身的安装一样简单,即使是对于最终用户来说也是如此,因为为许多平台提供了二进制安装程序,例如 Windows 和 Mac,也包括 64 位版本。(Linux 的发行版以源代码的形式提供,并附有编译它们的说明,但许多 Linux 发行版要么已经自动提供了 Python,要么使得从软件包仓库安装它变得非常容易)。
检查完整的 Python 发行版
很有可能你已经在系统中安装了完整的 Python 发行版。你可以通过启动 Blender 并检查控制台窗口(术语控制台窗口指的是在 Windows 上并行启动的 DOSBox 或在其他系统上从其中启动 Blender 的 X 终端窗口)来验证这一点,看看是否显示以下文本:
Compiled with Python version 2.6.2.
Checking for installed Python... got it!
如果它显示了,那么你不需要做任何事情,可以直接跳到交互式 Python 控制台部分。如果它显示以下消息,那么你必须采取一些行动:
Compiled with Python version 2.6.2.
Checking for installed Python... No installed Python found.
Only built-in modules are available. Some scripts may not run.
Continuing happily.
安装完整的 Python 发行版
Windows 或 Mac 上完整 Python 安装的步骤如下:
-
从
www.python.org/download/下载一个合适的安装程序。在撰写本文时,最新的稳定版 2.6 是 2.6.2(用于 Blender 2.49)。安装最新稳定版本通常是个好主意,因为它将包含最新的错误修复。但是,请确保使用与 Blender 编译时相同的重大版本。即使 Blender 是用 2.6.2 编译的,使用 2.6.3 版本也是可以的。但是,如果你使用的是用 Python 2.5.4 编译的较旧版本的 Blender,你必须安装最新的 Python 2.5.x 版本(或者如果可能的话,升级到 Blender 2.49)。 -
运行安装程序:在 Windows 上,安装程序会提供选择 Python 安装位置。你可以选择任何你喜欢的位置,但如果选择默认位置,Blender 几乎肯定可以找到这里安装的模块,无需设置
PYTHONPATH变量。(见下文) -
(重新)启动 Blender。Blender 控制台应该显示以下文本:
Compiled with Python version 2.6.2. Checking for installed Python... got it!如果没有,可能需要设置
PYTHONPATH变量。请参阅 Blender 维基以获取详细信息:wiki.blender.org/index.php/Doc:Manual/Extensions/Python
在 Ubuntu Linux 上,第一步不是必需的,可以通过使用内置的包管理器来安装:
sudo apt-get update
sudo apt-get install python2.6
其他发行版可能使用不同的包管理系统,因此您可能需要检查该系统的文档。在 Windows 上,可能需要设置 PYTHONPATH 环境变量,尽管在使用提供的包时不太可能需要这样做。
交互式 Python 控制台
要查看 Blender 实际查找模块的位置,您可以查看 Python 的 sys.path 变量。为此,您必须启动 Blender 的交互式 Python 控制台。请注意,您在这里使用的是不同的、可能令人困惑的概念——DOSBox 或与 Blender 主应用程序窗口一起启动并显示各种信息消息的终端窗口也被称为 控制台!我们现在想要使用的 Python 交互式控制台是从 脚本窗口 启动的:

一旦启动了交互式 Python 控制台,请输入以下命令:
import sys
print sys.path
注意,交互式 Python 控制台不会显示任何提示(除非在需要缩进的情况下,例如在 for 循环中)但您输入的内容将以不同的颜色(默认为白色在黑色背景上)显示,而返回的内容(将显示为蓝色或黑色)。前两个命令将使我们能够访问包含各种系统信息的 Python 的 sys 模块。我们在这里打印的 sys.path 变量将包含当我们尝试导入模块时将被搜索的所有目录。(请注意,导入 sys 总是会成功,因为 sys 是一个内置模块。)输出将类似于:
['C:\\Program Files\\Blender Foundation\\Blender', 'C:\\Program Files\\Blender Foundation\\Blender\\python26.zip', 'C:\\Python26\\Lib', 'C:\\Python26\\DLLs', 'C:\\Python26\\Lib\\lib-tk', 'C:\\Program Files\\Blender Foundation\\Blender', 'C:\\Python26', 'C:\\Python26\\lib\\site-packages','C:\\Python26\\lib\\site-packages\\PIL', 'C:\\PROGRA~1\\BLENDE~1\\Blender', 'C:\\Documents and Settings\\Michel\\Application Data\\Blender Foundation\\Blender\\.blender\\scripts', 'C:\\Documents and Settings\\Michel\\Application Data\\Blender
Foundation\\Blender\\.blender\\scripts\\bpymodules']
如果您的 Python 安装目录不在此列表中,那么在启动 Blender 之前,您应该设置 PYTHONPATH 变量。
探索内置模块,help() 函数
交互式 Python 控制台是一个探索内置模块的好平台。因为 Python 配备了两个非常有用的函数,help() 和 dir(),您可以直接访问 Blender(和 Python)模块中包含的大量信息,因为许多文档都是作为代码的一部分提供的。
对于不熟悉这些函数的人来说,这里有两个简短的示例,两个都是从交互式 Python 控制台运行的。要获取特定对象或函数的信息,请输入:
help(Blender.Lamp.Get)
信息将在同一控制台中打印:
Help on built-in function Get in module Blender.Lamp:
Lamp.Get (name = None):
Return the Lamp Data with the given name, None if not found, or
Return a list with all Lamp Data objects in the current scene,
if no argument was given.
help()函数将显示函数、类或模块的相关 docstring。在上一个例子中,这是Lamp类的Get()方法(函数)提供的信息。docstring是在函数、类或模块中定义的第一个字符串。当你定义自己的函数时,这样做也是一个好习惯。它可能看起来像这样:
def square(x):
"""
calculate the square of x.
"""
return x*x
我们现在可以像之前一样,将help函数应用于我们新定义的函数:
help(square)
输出随后显示:
Help on function square in module __main__:
square(x)
calculate the square of x.
在我们将要开发的程序中,我们将根据需要使用这种文档方法。
探索内置函数,dir()函数
dir()函数列出了一个对象的全部成员。这个对象可以是一个实例,也可以是一个类或模块。例如,我们可以将其应用于Blender.Lamp模块:
dir(Blender.Lamp)
输出将是一个包含Blender.Lamp模块所有成员的列表。你可以找到我们之前遇到的Get()函数:
['ENERGY', 'Falloffs', 'Get', 'Modes', 'New', 'OFFSET', 'RGB','SIZE', 'SPOTSIZE', 'Types', '__doc__', '__name__', '__package__','get']
一旦你知道一个类或模块有哪些成员,你就可以通过应用help()函数来检查这些成员的任何附加帮助信息。
当然,dir()和help()函数在你已经知道信息所在位置时最为有用。但如果是这样,它们确实是非常方便的工具。
熟悉内置编辑器
使用任何你喜欢的编辑器编写 Python 脚本,然后将脚本作为文本文件导入,但 Blender 的内置文本编辑器可能足以满足所有编程需求。它具有语法高亮、行号和自动缩进等便利功能,并允许你直接从编辑器中运行脚本。由于在遇到错误时能够直接获得反馈,因此直接从编辑器运行脚本在调试时是一个明显的优点。你不仅会得到一条信息,而且出错的行也会在编辑器中被突出显示。
此外,编辑器还附带了许多插件,其中成员自动建议和文档查看器对程序员来说非常方便。当然,你也可以自己编写额外的插件。
你可以通过从窗口菜单中选择文本编辑器来选择内置编辑器:

启动时,你将看到一个几乎空白的区域,底部只有一条按钮条:

我们可以选择默认的空文本缓冲区TX:Text,或者通过点击菜单按钮时出现的下拉菜单中的添加新项来创建一个新的空文本。
此新文本的默认名称将是TX:Text.001,,但您可以通过单击名称并更改它来将其更改为更有意义的内容。请注意,如果您想将此文本保存到外部文件(使用文本 | 另存为...),文本的名称与文件名不同(尽管通常保持它们相同以避免混淆)。保存文本为外部文件不是强制性的;文本是 Blender 对象,当您保存.blend文件时,它们会与其他所有信息一起保存。
通过从菜单按钮的下拉菜单中选择打开新文件,而不是添加新文件,可以以文本形式打开外部文件。如果由于某种原因,当 Blender 启动时外部文件和相关文本不同步,将显示一个不同步的按钮。点击该按钮时,会显示一系列选项以解决该问题。
一旦选择了一个新文本或现有文本,屏幕底部的菜单栏将更新,并添加一些额外的菜单选项:

文本文件菜单提供了打开或保存文件或运行编辑器中的脚本的选项。它还展示了一些模板脚本,这些脚本可以作为您自己脚本的起点。如果您选择这些模板之一,将创建一个新的文本缓冲区,其中包含所选模板的副本。
编辑菜单包含剪切和粘贴功能,以及搜索和替换文本或跳转到所选行号的选项。
格式菜单提供了缩进和取消缩进所选文本的选项,以及转换空白字符的选项。后者在 Python 解释器抱怨意外的缩进级别时非常有用,尽管您的文件似乎没有问题。如果发生这种情况,您可能以混淆 Python 的方式混合了制表符和空格(因为它们在解释器看来是不同的),一种可能的解决方案是首先将所选文本转换为空格,然后再将其转换回制表符。这样,混合的空格和制表符将再次以统一的方式使用。
编辑器示例
为了熟悉编辑器,通过选择文本 | 新建创建一个新的文本缓冲区,并输入以下示例行:
import sys
print sys.path
键盘上的大多数键将以熟悉的方式工作,包括删除、退格和回车。剪切、粘贴和复制的快捷键在编辑菜单中分别列出为Alt + X、Alt + V和Alt + C,但 Windows 用户熟悉的Ctrl键等效键Ctrl + X、Ctrl + V和Ctrl + C(同样有效)。完整的键盘映射可以在 Blender 维基上查阅,wiki.blender.org/index.php/Doc:Manual/Extensions/Python/Text_editor
通过单击并拖动鼠标可以选择文本的一部分,但您也可以在按住Shift键的同时移动文本光标来选择文本。
文本默认将不进行着色,但通过启用语法高亮,阅读脚本可以变得更容易。点击小型的AB按钮可以切换此功能(当语法高亮关闭时为黑白,开启时为彩色)。像 Blender 的许多方面一样,文本颜色可以在用户首选项窗口的主题部分进行自定义。
另一个非常方便启用的功能,尤其是在调试脚本时,是行号显示。(你可能会一次性写出无瑕疵的代码,但不幸的是,我并不那么聪明。)每个将被显示的 Python 错误信息都将包含文件名和行号,并且出错的行将被突出显示。但是,如果有调用函数,它们的行号将在错误信息中显示,但不会被突出显示,因此启用行号显示将使你能够快速定位问题所在代码的调用上下文。行号显示可以通过点击行号按钮来启用。
通过按下Alt + P来运行脚本。如果没有遇到错误,编辑器中不会显示任何内容,但输出将显示在控制台(即从 Blender 启动的 DOSBox 或 X 终端,不是我们之前遇到的 Python 交互式控制台)。
第一步:hello world
传统上要求每一本编程书籍都要有一个“hello world”示例,我们为什么要冒犯人们呢?我们将实现并运行一个简单的对象实例化脚本,并展示如何将其集成到 Blender 的脚本菜单中。我们还将展示如何对其进行文档化并在帮助系统中添加条目。最后,我们将讨论将脚本作为.blend文件分发或作为用户安装到scriptdir中的脚本的分发优缺点。
让我们编写一些代码!你可以直接在交互式 Python 控制台中输入以下行,或者你可以在 Blender 的文本编辑器中打开一个新的文本文件,然后按下Alt + P来运行脚本。这是一个简短的脚本,但我们将详细地讲解它,因为它展示了 Blender Python API 的许多关键特性。
#!BPY
import Blender
from Blender import Scene, Text3d, Window
hello = Text3d.New("HelloWorld")
hello.setText("Hello World!")
scn = Scene.GetCurrent()
ob = scn.objects.new(hello)
Window.RedrawAll()
第一行将此脚本标识为 Blender 脚本。这不是运行脚本所必需的,但如果我们想使此脚本成为 Blender 菜单结构的一部分,我们需要它,所以最好立即习惯它。
你几乎可以在任何 Blender 脚本中找到第二行(它将被突出显示),因为它为我们提供了访问 Blender Python API 中的类和函数的权限。同样,第三行为我们提供了访问我们在此脚本中需要的 Blender 模块的特定子模块的权限。当然,我们可以将它们作为Blender模块的成员来访问(例如,Blender.Scene),但显式导入它们可以节省一些输入并提高可读性。
下面的两行首先创建一个Text3d对象并将其分配给变量hello。在 Blender 中,Text3d对象将具有HelloWorld的名称,因此用户可以通过此名称引用此对象。此外,这也是在 Outliner 窗口和对象被选中时的左下角可见的名称。如果已经存在具有相同名称的同类型对象,Blender 将在名称中添加一个数字后缀以使其唯一。例如,如果我们运行脚本两次,HelloWorld可能会变成HelloWord.001。`
默认情况下,新创建的Text3d对象将包含文本**Text**,我们可以使用setText()方法将其更改为**Hello** **World!**
默认情况下,新创建的 Blender 对象是不可见的,我们必须将其与一个场景关联起来,所以接下来的几行代码检索当前场景的引用并将Text3d对象添加到其中。Text3d对象不是直接添加到场景中,而是scene.objects.new()方法将Text3d对象嵌入到一个通用的 Blender 对象中,并返回后者的引用。通用的 Blender 对象包含所有对象共有的信息,例如位置,而Text3d对象则包含特定的信息,例如文本字体。
最后,我们告诉窗口管理器刷新任何由于添加新对象而需要刷新的窗口。
将脚本集成到 Blender 的菜单中
您的脚本不必是二等公民。它可以成为 Blender 的一部分,与 Blender 附带的所有捆绑脚本一样。它可以添加到 View3D 窗口顶部的**添加**菜单中。
注意
实际上,**添加**菜单位于用户偏好设置窗口的底部标题栏中,但由于这个窗口位于 View3D 窗口之上,并且默认情况下仅显示标题栏,所以它看起来像是 View3D 窗口顶部的标题栏。许多用户已经习惯了它,以至于把它看作是 View3D 窗口的一部分。
它可以像任何其他脚本一样向 Blender 的帮助系统提供信息。以下几行代码使得这一点成为可能:
"""
Name: 'HelloWorld'
Blender: 249
Group: 'AddMesh'
Tip: 'Create a Hello World text object'
"""
我们以一个包含多行文本的独立字符串开始脚本。
注意
每一行都以一个标签开头,后面跟着一个冒号和一个值。冒号应紧随标签之后。不应该有任何间隔空格,否则我们的脚本将*不会*出现在任何菜单中。
每行开头的标签具有以下作用:
-
```
Name(一个字符串)定义了脚本在菜单中显示的名称` -
```
Blender(一个数字)定义了使用脚本所需的 Blender 的最小版本` -
``
组(一个字符串)是脚本菜单下的子菜单,该脚本应该分组。如果我们的脚本要在 View3D 窗口的添加 | 网格菜单下显示(也可以通过按Space键访问),则应读取AddMesh。如果它应该位于脚本菜单的另一个子菜单下,则可以是例如Wizards或Object。除了必要的标签外,还可以添加以下可选标签:` -
`版本`(一个字符串)是脚本在任何你喜欢的格式中的版本。 -
`提示`(一个字符串)是在**脚本**菜单中的菜单项上悬停时显示的工具提示信息。如果脚本属于`AddMesh`组,即使我们在这里定义了一个工具提示,也不会显示。

在 Blender 的帮助系统中集成脚本
Blender 有一个集成的帮助系统,可以从屏幕顶部的**帮助**菜单访问。它通过**脚本** **帮助** **浏览器**条目提供对在线资源以及注册脚本的访问信息。一旦选择,它将显示一组下拉菜单,每个菜单对应一个组,你可以从中选择一个脚本并查看其帮助信息。
如果我们想将我们的脚本添加到集成的帮助系统中,我们需要定义一些额外的全局变量:
__author__ = "Michel Anders (varkenvarken)"
__version__ = "1.00 2009/08/01"
__copyright__ = "(c) 2009"
__url__ = ["author's site, http://www.swineworld.org"]
__doc__ = """
A simple script to add a Blender Text object to a scene.
It takes no parameters and initializes the object to contain the
text 'Hello World'
"""
这些变量应该都是不言自明的,除了`__url__`变量——这个变量将包含一个字符串列表,其中每个字符串由一个简短描述、一个逗号和一个 URL 组成。生成的帮助屏幕将看起来像这样:

``现在我们只剩下测试它并将此脚本放置在适当的位置了。我们可以通过按Alt + P来测试脚本。如果没有遇到错误,这将导致我们的Hello World Text3d对象被添加到场景中,但脚本尚未被添加到添加菜单中。```
如果要将脚本添加到**添加**菜单,它必须位于 Blender 的脚本目录中。为此,首先将脚本保存到文本缓冲区,并以一个有意义的名称保存为文件。接下来,确保这个文件位于 Blender 的脚本目录中。这个目录被称为`scripts`,是 Blender 配置目录`.blender`的子目录。它位于 Blender 的安装目录中,或者在 Windows 中位于`Application` `Data`目录中。找到我们的最简单方法就是再次查看`sys.path`变量,看看哪个列出的目录以`.blender\scripts`结尾。
位于 Blender 的`scripts`目录中的脚本将在启动时自动执行,因此我们的 hello world 脚本将在我们启动 Blender 时随时可用。如果我们想让 Blender 重新检查脚本目录(这样我们就不必重新启动 Blender 来查看我们的新添加项),我们可以在交互式控制台中选择**脚本 | 更新菜单**。
py`# Don't get confused, stay objective As you may have noticed the word **object** is used in two different (possibly confusing) ways. In Blender almost anything is referred to as an Object. A `Lamp` for instance is an Object, but so is a `Cube` or a `Camera`. **Objects** are things that can be manipulated by the user and have for example a position and a rotation. In fact, things are a little bit more structured (or complicated, as some people say): any `Blender` object contains a reference to a more specific object called the **data** **block**. When you add a `Cube` object to an empty scene you will have a generic object at some location. That object will be called `Cube` and will contain a reference to another object, a `Mesh`. This `Mesh` object is called `Cube` by default as well but this is fine as the namespaces of different kind of objects are separate. This separation of properties common to all objects (such as position) and properties specific to a single type of object (such as the energy of a `Lamp` or the vertices of a `Mesh`) is a logical way to order sets of properties. It also allows for the instantiation of many copies of an object without consuming a lot of memory; we can have more than one object that points to the same `Mesh` object for example. (The way to achieve that is to create a **linked** **duplicate**, using *Alt + D*.) The following diagram might help to grasp the concept:  Another way the word **object** is used is in the Python sense. Here we mean an instance of a class. The Blender API is object-oriented and almost every conceivable piece of structured data is represented by an object instanced from a class. Even fairly abstract concepts such as an **Action** or an **IPO** (abstract in the sense that they do not have a position somewhere in your scene) are defined as classes. How we refer to the Blender or to the Python sense of the word object in this book will mostly be obvious from the context if you keep in mind this distinction. But if not, we tend to write the Blender sense as *Object* and the Python sense as *object* or *object instance*. # Adding different types of object from a script Adding other types of objects is, in many cases, just as straightforward as adding our text object. If we want our scene to be populated in a way that enabled us to render it, we would have to add a camera and a lamp to make things visible. Adding a camera to the same scene could be done like this (assuming we still have a reference to our active scene in the `scn` variable): 从 Blender 导入 Camera 类,创建新的相机数据:cam = Camera.New() # 创建新的相机数据
第二章。创建和编辑对象
从某种意义上说,网格是 3D 应用程序中最基本的对象类型。它们构成了大多数可见对象的基础,并且是可能进一步绑定和动画的原始材料。本章讨论了网格的创建以及操纵网格对象的方法,无论是作为一个整体还是作为它所包含的各个实体——顶点、边和面。

在本章中,你将学习:
-
如何创建可配置的网格对象
-
如何设计图形用户界面
-
如何让脚本存储用户选择以便以后重用
-
如何在网格中选择顶点和面
-
如何将一个对象附加到另一个对象上
-
如何创建组
-
如何修改网格
-
如何从命令行运行 Blender 并在后台渲染
-
如何处理命令行参数
可怕的爬虫——一个配置对象的图形用户界面
实例化一个唯一的 Blender 对象(就像我们在第一章的“hello world”示例中所做的那样)可能是一个好的编程练习,但当一个对象的创建脚本包含内置方法(如复制对象)或修改器(如阵列修改器)不足以满足需求时,它才能真正发挥其作用。
一个很好的例子是我们想要创建一个或多个对象变体,并且这些变体需要易于最终用户配置。例如,螺母和螺栓有各种形状和尺寸,因此 Blender 附带了一个脚本来创建它们。网上还有许多其他脚本,可以创建从机械齿轮到楼梯,从树木到教堂圆顶等各种东西。
在本节中,我们展示了如何构建一个小应用程序,它可以创建各种类似虫子的生物,并附带一个简单但有效的图形用户界面来设置许多可配置的参数。此应用程序还会存储用户偏好设置以供以后重用。
构建用户界面
设计、构建和测试一个图形用户界面可能是一项艰巨的任务,但 Blender API 为我们提供了工具,使这项任务变得容易得多。Blender.Draw模块提供了简单、常用且易于配置的组件,可以快速构建用户界面。Blender.BGL模块提供了从头开始设计图形用户界面的所有工具。我们将主要使用前者,因为它几乎包含了我们所需的一切,但我们也会给出后者的一个示例,以设计一个简单的错误弹出窗口。我们的主要用户界面将看起来像这样:

当我们从 添加 菜单(通常可以通过屏幕顶部的菜单栏或按 3D 视图中的空格键访问)调用我们的脚本时,之前的菜单将弹出,用户可以调整参数以符合自己的喜好。当按下 确定 按钮时,脚本生成一个类似昆虫的网格。也可以通过按 Esc 键退出弹出窗口,在这种情况下,脚本将终止而不会生成网格。
创建虫子——需要一些组装
我们的使命是从一小块可能连接在一起的构建块中创建简单的生物。我们脚本的概要如下:
-
导入我们生物的构建块。
-
绘制用户界面。
-
根据用户定义的构建块组装生物网格。
-
将网格作为对象插入场景。
我们逐步通过脚本,详细展示相关部分。(完整的脚本作为 creepycrawlies.py 提供。)第一步涉及创建适合组装的身体部分。这意味着我们必须在 Blender 中建模这些部分,定义合适的关节并将这些关节标记为顶点组。然后我们通过使用我们在下一章中再次遇到的脚本将这些网格作为 Python 代码导出,该脚本处理顶点组。
目前,我们只是将生成的 Python 代码简单地用作包含定义每个身体部分的几个顶点列表的模块。我们必须确保这个模块在 Python 路径中的某个位置,例如,.blender\scripts\bpymodules 将是一个合理的选项,或者也可以是用户 scriptdir。包含网格构建块的 Python 文件命名为 mymesh.py,因此我们代码的第一部分包含以下 import 语句:
import mymesh
创建用户界面
使用 Draw.Create() 创建所需的按钮,并通过 Draw.PupBlock() 组装和初始化这些按钮,绘制简单的用户界面是一个问题。
与某些编程语言中可用的完整库相比,这有些局限,但非常容易使用。基本思想是创建交互式对象,如按钮,然后将它们组装在对话框中显示给用户。同时,对话框还说明了按钮可能产生的值的限制。对话框或弹出窗口将在光标位置显示。Blender 能够生成更复杂的用户界面,但到目前为止,我们坚持使用基本功能。
虽然 Draw.Create() 可以生成切换按钮和字符串的输入按钮,但对我们来说,我们的应用只需要整数和浮点值的输入按钮。变量的类型(例如浮点值或整数)由提供给 Draw.Create() 的默认值的类型决定。**OK** 按钮由 Draw.PupBlock() 自动显示。这个函数接受一个元组列表作为参数,每个元组定义了一个要显示的按钮。每个元组由要显示在按钮上的文本、使用 Draw.Create() 创建的按钮对象、允许的最小和最大值以及当鼠标悬停在按钮上时显示的工具提示文本组成。
Draw = Blender.Draw
THORAXSEGMENTS = Draw.Create(3)
TAILSEGMENTS = Draw.Create(5)
LEGSEGMENTS = Draw.Create(2)
WINGSEGMENTS = Draw.Create(2)
EYESIZE = Draw.Create(1.0)
TAILTAPER = Draw.Create(0.9)
if not Draw.PupBlock('Add CreepyCrawly', [
('Thorax segments:' , THORAXSEGMENTS, 2, 50,'Number of thorax segments'),
('Tail segments:' , TAILSEGMENTS, 0, 50, 'Number of tail segments'),
('Leg segments:' , LEGSEGMENTS, 2, 10, 'Number of thorax segments with legs'),
('Wing segments:' , WINGSEGMENTS, 0, 10, 'Number of thorax segments with wings'),
('Eye size:' , EYESIZE, 0.1,10, 'Size of the eyes'),
('Tail taper:' , TAILTAPER, 0.1,10, 'Taper fraction of each tail segment'),]):
return
如您所见,我们将输入按钮的可能值限制在一个合理的范围内(胸和尾段最多为 50),以防止出现不希望的结果(如果内存或处理能力稀缺,巨大的值可能会使您的系统瘫痪)。
记忆选择
如果我们能够记住用户的选择,以便在脚本再次运行时呈现最后设置,那将非常方便。但在 Blender 中,每个脚本都是独立运行的,一旦脚本结束,脚本内的所有信息都会丢失。因此,我们需要某种机制以持久的方式存储信息。为此,Blender API 有一个 Registry 模块,允许我们通过任意键索引来保持值(并在磁盘上也是如此)。
如果我们想要添加此功能,我们的 GUI 初始化代码本身变化不大,但前面会添加代码来检索已记住的值(如果有的话),后面会跟随着保存用户选择的代码:
reg = Blender.Registry.GetKey('CreepyCrawlies',True)
try:
nthorax=reg['ThoraxSegments']
except:
nthorax=3
try:
ntail=reg['TailSegments']
except:
ntail=5
... <similar code for other parameters> ...
Draw = Blender.Draw
THORAXSEGMENTS = Draw.Create(nthorax)
TAILSEGMENTS = Draw.Create(ntail)
LEGSEGMENTS = Draw.Create(nleg)
WINGSEGMENTS = Draw.Create(nwing)
EYESIZE = Draw.Create(eye)
TAILTAPER = Draw.Create(taper)
if not Draw.PupBlock('Add CreepyCrawly', [\
... <identical code as in previous example> ...
return
reg={'ThoraxSegments':THORAXSEGMENTS.val,
'TailSegments' :TAILSEGMENTS.val,
'LegSegments' :LEGSEGMENTS.val,
'WingSegments' :WINGSEGMENTS.val,
'EyeSize' :EYESIZE.val,
'TailTaper':TAILTAPER.val}
Blender.Registry.SetKey('CreepyCrawlies',reg,True)
实际上读取和写入我们的注册表条目被突出显示。True 参数表示,如果数据不在内存中,我们希望从磁盘检索我们的数据,或者在保存时也将其写入磁盘,以便即使我们停止 Blender 并稍后重新启动,我们的脚本也能访问这些保存的信息。实际接收或写入的注册表条目是一个可以存储我们想要的任何数据的字典。当然,可能还没有注册表条目,在这种情况下,我们会得到一个 None 值——这种情况通过 try … except … 语句得到了处理。
Blender 图形的全部力量
对于许多应用来说,一个弹出对话框就足够了,但如果它不符合你的要求,Blender 的 Draw 模块提供了更多构建块来创建用户界面,但这些构建块需要更多努力才能将它们粘合在一起以形成一个工作应用。
我们将使用这些构建块来创建一个错误弹出窗口。这个弹出窗口仅仅在令人不安的背景色上显示一条消息,但很好地说明了用户操作(如按键或按钮点击)是如何与图形元素相联系的。
from Blender import Window,Draw,BGL
def event(evt, val):
if evt == Draw.ESCKEY:
Draw.Exit() # exit when user presses ESC
return
def button_event(evt):
if evt == 1:
Draw.Exit()
return
def msg(text):
w = Draw.GetStringWidth(text)+20
wb= Draw.GetStringWidth('Ok')+8
BGL.glClearColor(0.6, 0.6, 0.6, 1.0)
BGL.glClear(BGL.GL_COLOR_BUFFER_BIT)
BGL.glColor3f(0.75, 0.75, 0.75)
BGL.glRecti(3,30,w+wb,3)
Draw.Button("Ok",1,4,4,wb,28)
Draw.Label(text,4+wb,4,w,28)
def error(text):
Draw.Register(lambda:msg(text), event, button_event)
error()函数是用户开始和结束的地方;它告诉 Blender 要绘制什么,将事件(如按钮点击)发送到何处,将按键发送到何处,并开始交互。lambda函数是必要的,因为我们传递给Draw.Register()以绘制东西的函数不能接受参数,但我们希望在每次调用error()时传递不同的文本参数。lambda函数基本上定义了一个没有参数但有文本封装的新函数。
msg()函数负责在屏幕上绘制所有元素。它使用BGL.glRecti()函数绘制一个带有文本的彩色背景(使用Draw.Label()),并绘制一个分配有事件编号1的 OK 按钮(使用Draw.Button())。当用户点击 OK 按钮时,这个事件编号被发送到事件处理器——我们传递给Draw.Register()的button_event()函数。当事件处理器被调用并带有事件编号1时,它所做的所有事情就是通过调用Draw.Exit()来终止Draw.Register()函数,因此我们的error()函数可以返回。
创建一个新的网格对象
一旦我们从mymesh模块中检索到我们的顶点坐标和面索引列表,我们需要一种方法在我们的场景中创建一个新的Mesh对象,并将MVert和MFace对象添加到这个网格中。这可能像这样实现:
me=Blender.Mesh.New('Bug')
me.verts.extend(verts)
me.faces.extend(faces)
scn=Blender.Scene.GetCurrent()
ob=scn.objects.new(me,'Bug')
scn.objects.active=ob
me.remDoubles(0.001)
me.recalcNormals()
第一行创建了一个名为Bug的新Mesh对象。它将不包含任何顶点、边或面,并且不会嵌入到 Blender 对象中,也不会连接到任何Scene。如果网格的名字已经存在,它将附加一个唯一的数字后缀(例如,Bug.001)。
接下来的两行实际上在网格内部创建几何形状。verts属性是我们引用MVert对象列表的地方。它有一个extend()方法,该方法将接受一个包含顶点创建的 x、y 和 z 坐标的元组的列表。同样,faces属性的extend()方法将接受一个包含指向顶点列表的三个或更多索引的元组的列表,这些索引一起定义了一个面。在这里顺序很重要:我们需要首先添加新顶点;否则,新创建的面无法引用它们。没有必要定义任何边,因为添加面也会创建隐含的边,这些边尚未存在。
网格本身还不是用户可以操作的对象,所以在接下来的几行(突出显示)中,我们检索当前场景并向场景添加一个新对象。new()的参数是我们之前创建的Mesh对象以及我们想要给对象起的名字。给对象起的名字可能与网格的名字相同,因为网格名字和对象名字存在于不同的命名空间中。与网格一样,现有的名字将通过添加后缀来变得唯一。如果省略了名字,新对象将具有其参数的类型作为默认名字(在这种情况下是Mesh)。
新创建的对象将被选中但不会激活,所以我们通过将我们的对象分配给scene.objects.active来纠正这一点。
当我们从各种顶点集合中组合我们的网格时,结果可能不像我们希望的那样干净,因此,最后的两个动作确保我们没有占据空间中几乎相同位置的顶点,并且所有面的法向量都一致指向外部。
转换网格拓扑
从积木块创建生物体需要我们在将它们粘合在一起之前,对这些积木块进行复制、缩放和镜像。在 Blender 2.49 中,这意味着我们必须定义一些实用函数来执行这些操作,因为这些操作在 API 中不存在。我们在工具模块中定义了这些实用函数,但在这里我们突出了一些,因为它们展示了有趣的方法。
一些动作,如围绕中点缩放或顶点的平移是直接的,但将一组顶点连接到另一组是棘手的,因为我们希望防止边交叉并保持面平坦且无扭曲。我们不能简单地连接两组顶点(或边环)。但通过尝试边环上的不同起点并检查这种选择是否最小化所有顶点对之间的距离,我们确保没有边交叉且扭曲最小(尽管如果边环形状非常不同,我们无法防止面扭曲)。
连接边环的代码概述
在创建新面的函数中,我们必须执行以下步骤:
-
检查两个边环是否长度相等且非零。
-
对于循环 1 中的每个边:
-
找到循环 2 中最接近的边。
-
创建一个连接这两个边的面。
-
实现此概述的函数看起来相当复杂:
def bridge_edgeloops(e1,e2,verts):
e1 = e1[:]
e2 = e2[:]
faces=[]
if len(e1) == len(e2) and len(e1) > 0 :
该函数接受两个边列表和一个顶点列表作为参数。边由两个整数的元组表示(verts列表中的索引),顶点由 x、y 和 z 坐标的元组表示。
我们首先做的事情是复制两个边列表,因为我们不希望在原始上下文中破坏这些列表。我们将要构建的面列表初始化为空列表,并对两个边列表的长度进行合理性检查。如果检查无误,我们继续下一步:
for a in e1:
distance = None
best = None
enot = []
我们遍历第一个列表中的每个边,将这个边称为a。distance参数将保存到第二个边列表中最近边的距离,而best将是该边的引用。enot是一个列表,它将累积第二个列表中距离best更远的所有边。
每次迭代的末尾,enot将保存第二个列表中除一个之外的所有边——我们认为是最近的那个。然后我们将enot重新分配给第二个列表,这样第二个列表在每个迭代中就会减少一个边。一旦第二个边列表耗尽,我们就完成了:
while len(e2):
b = e2.pop(0)
我们正在考虑的第二个列表中的当前边被称为b。为了我们的目的,我们将a和b之间的距离定义为a和b中对应顶点的距离之和。如果这个距离更短,我们将其定义为到b翻转顶点的距离之和。如果最后一种情况适用,我们在边b中交换顶点。这看起来可能是一种复杂的方法,但通过将两个距离相加,我们确保了相对共线的边被优先考虑,从而减少了将要构建的非平面面的数量。通过检查翻转第二个边是否会缩短距离,我们防止了形成扭曲或蝴蝶结四边形,如下面的图示所示:

实现将类似于之前的图示,其中高亮的 vec 是Mathutil.Vector的别名,将我们的 x、y 和 z 坐标的元组转换为适当的向量,我们可以从中减去、相加并计算长度。
首先,我们计算距离:
d1 = (vec(verts[a[0]]) - vec(verts[b[0]])).length + \
(vec(verts[a[1]]) – vec(verts[b[1]])).length
然后我们检查翻转 b 边是否会导致距离更短:
d2 = (vec(verts[a[0]]) - vec(verts[b[1]])).length + \
(vec(verts[a[1]]) - vec(verts[b[0]])).length
if d2<d1 :
b =(b[1],b[0])
d1 = d2
如果计算出的距离不是最短的,我们将边留到下一次迭代,除非它是我们遇到的第一个:
if distance == None or d1<distance :
if best != None:
enot.append(best)
best = b
distance = d1
else:
enot.append(b)
e2 = enot
faces.append((a,best))
最后,我们将由两个边组成的元组列表转换为四个索引的元组列表:
return [(a[0],b[0],b[1],a[1]) for a,b in faces]
这个脚本还有很多内容,我们将在下一章中重新访问creepycrawlies.py,当我们添加修改器和顶点组并给我们的模型绑定时。插图显示了脚本可以创建的野兽样本。

用 Blender 风格的条形图惊艳你的老板
为了证明 Blender 除了交互式创建 3D 图形之外,还能适应许多任务,我们将向您展示如何导入外部数据(CSV 格式的电子表格)并自动化创建和渲染条形图 3D 表示的任务。

策略是运行 Blender,使用参数将其指向运行一个读取.csv文件、渲染图像并在完成后保存该图像的脚本。为了实现这一点,我们需要一种方法来使用正确的参数调用 Blender。我们将在稍后的脚本中介绍这一点,但首先让我们看看如何传递参数给 Blender 以使其运行 Python 脚本:
blender -P /full/path/to/barchart.py
也可以通过命名文本缓冲区来在.blend文件内部运行脚本。注意在这种情况下参数的顺序——.blend文件排在前面:
blender barchart.blend -P barchart.py
我们还需要一种方式来指定传递给我们的脚本的参数。与 API 文档中描述的不同,我们可以像这样从 Python 中访问命令行参数:
import sys
print sys.argv
最后这段代码将打印出所有参数,包括作为第一个参数的 Blender 可执行文件名。当使用这个列表时,我们的脚本必须跳过任何打算用于 Blender 本身的参数。任何只打算用于我们的脚本且不应该被 Blender 本身解释的参数应该跟在选项结束参数之后,即双横线(--)。
最后,我们不希望 Blender 弹出并显示交互式 GUI。相反,我们将指示它在后台运行,并在完成后退出。这是通过传递-b选项来完成的。将这些放在一起,命令行将看起来像这样:
blender -b barchart.blend -P barchart.py –- data.csv
如果 Blender 以后台模式运行,必须指定一个.blend文件,否则 Blender 将会崩溃。如果我们必须指定一个.blend文件,我们也可以使用内部文本作为我们的 Python 脚本,否则我们就必须将两个文件放在一起而不是一个。
柱状图脚本
在这里,我们以块的形式展示代码的相关部分(完整的文件作为barchart.blend提供,其中包含内嵌的barchart.py文本)。我们首先创建一个新的World对象,并将其天顶和地平线颜色设置为中性全白(以下代码的高亮部分):
if __name__ == '__main__':
w=World.New('BarWorld')
w.setHor([1,1,1])
w.setZen([1,1,1])
接下来,我们检索传递给 Blender 的最后一个参数,并检查扩展名是否为.csv文件。在实际的生产代码中,当然会有更复杂的错误检查:
csv = sys.argv[-1]
if csv.endswith('.csv'):
如果它有正确的扩展名,我们将创建一个名为BarScene的新Scene,并将它的world属性设置为我们的新创建的世界(这受到了 Blender Artists 上jessethemid的一个更复杂的脚本的启发blenderartists.org/forum/showthread.php?t=79285)。后台模式不会加载任何默认的.blend文件,所以默认场景将不包含任何对象。然而,为了确保这一点,我们创建了一个具有有意义名称的新空场景,它将包含我们的对象:
sc=Scene.New('BarScene')
sc.world=w
sc.makeCurrent()
然后,我们将文件名传递给一个函数,该函数将barchart对象添加到当前场景中,并返回图表的中心,以便我们的addcamera()函数可以使用它来定位相机。我们还添加了一个灯来使渲染成为可能(否则我们的渲染将会是全黑的)。
center = barchart(sys.argv[-1])
addcamera(center)
addlamp()
渲染本身很简单(我们将在第八章 Rendering and Image Manipulation 中遇到更复杂的例子),我们将检索包含所有渲染信息的渲染上下文,例如哪个帧、输出类型、渲染的大小等等。由于大多数属性都有合理的默认值,我们只需将格式设置为 PNG 并渲染即可。
context=sc.getRenderingContext()
context.setImageType(Scene.Render.PNG)
context.render()
最后,我们将输出目录设置为空字符串,以便我们的输出指向当前工作目录(我们调用 Blender 时的目录)并保存我们的渲染图像。图像将具有与传递给第一个参数的.csv文件相同的基名,但将具有.png扩展名。我们检查了文件名以.csv结尾,因此可以安全地从文件名中删除最后四个字符并添加.png
context.setRenderPath('')
context.saveRenderedImage(csv[:-4]+'.png')
添加一盏灯与添加任何其他对象并没有太大区别,它与“hello world”示例非常相似。我们创建一个新的Lamp对象,将其添加到当前场景中,并设置其位置。Lamp对象当然有许多可配置的选项,但在这个例子中我们选择默认的非定向灯。高亮显示的代码显示了某些典型的 Python 惯用语:loc是一个包含三个值的元组,但setLocation()需要三个单独的参数,因此我们使用*符号来表示我们希望将元组解包为单独的值:
def addlamp(loc=(0.0,0.0,10.0)):
sc = Scene.GetCurrent()
la = Lamp.New('Lamp')
ob = sc.objects.new(la)
ob.setLocation(*loc)
添加相机稍微复杂一些,因为我们必须将其指向我们的条形图并确保视图角度足够宽,以便可以看到一切。在这里我们定义了一个透视相机并设置了一个相当宽的角度。因为默认相机已经沿 z 轴定位,所以我们不需要设置任何旋转,只需将位置设置为从中心沿 z 轴远离 12 个单位,如以下代码的倒数第二行所示:
def addcamera(center):
sc = Scene.GetCurrent()
ca = Camera.New('persp','Camera')
ca.angle=75.0
ob = sc.objects.new(ca)
ob.setLocation(center[0],center[1],center[2]+12.0)
sc.objects.camera=ob
barchart函数本身并没有太多惊喜。我们打开传入的文件名,并使用 Python 的标准csv模块从文件中读取数据。我们将所有列标题存储在xlabel中,并将其他数据存储在rows中。
from csv import DictReader
def barchart(filename):
csv = open(filename)
data = DictReader(csv)
xlabel = data.fieldnames[0]
rows = [d for d in data]
为了将我们的条形图缩放到合理的值,我们必须确定数据的极值。每个记录的第一列包含 x 值(或标签),因此我们将其排除在我们的计算之外。因为每个值都存储为字符串,所以我们必须将其转换为浮点值进行比较。
maximum = max([float(r[n]) for n in data.fieldnames[1:] for r in rows])
minimum = min([float(r[n]) for n in data.fieldnames[1:] for r in rows])
为了创建实际的条形图,我们遍历所有行。因为 x 值可能是一个文本标签(例如,月份的名称),我们保留一个单独的数值 x 值来定位条形图。x 值本身是通过label()函数添加到场景中的Text3d对象,而 y 值是通过bar()函数添加的适当缩放的Cube对象来可视化的。这里没有显示label()或bar()函数。
for x,row in enumerate(rows):
lastx=x
label(row[xlabel],(x,10,0))
for y,ylabel in enumerate(data.fieldnames[1:]):
bar(10.0*(float(row[ylabel])-minimum)/maximum,(x,0,y+1))
x = lastx+1
最后,我们为每个列(即每组数据)添加其自己的列标题作为标签。我们存储了 x 值的数量,因此我们可以通过将其除以二(y 分量设置为 5.0,因为我们已将所有 y 值缩放到 0-10 的范围内)来返回条形图的中心。
for y,ylabel in enumerate(data.fieldnames[1:]):
label(ylabel,(x,0,y+0.5),'x')
return (lastx/2.0,5.0,0.0)
小贴士
Windows 技巧:发送到
一旦你有了包含正确 Python 脚本和已经从命令行中找到正确调用方式.blend文件,你可以通过创建一个SendTo程序来更紧密地将它集成到 Windows XP 中。在这个例子中,SendTo程序(一个.BAT文件)是任何接受单个文件名作为参数并对其执行操作的程序。它必须位于SendTo目录中——这个目录可能位于系统配置的不同位置。通过点击开始按钮,选择运行,并输入sendto而不是命令,可以轻松找到它。这将打开正确的目录。在这个目录中,你可以放置.BAT文件,在我们的例子中我们称之为BarChart.BAT,它将包含一个单独的命令:/full/path/to/blender.exe /path/to/barchart.blend -P barchart.py -- %1(注意百分号)。现在我们只需右键单击我们遇到的任何.csv文件,然后可以从SendTo菜单中选择BarChart.BAT,嘿, presto,一个.png文件将出现在我们的.csv旁边。
奇怪的面——在网格中选择和编辑面
Blender 已经提供了一系列选项来选择和操作网格的面、边和顶点,无论是通过内置方法还是通过 Python 扩展脚本。但如果你想根据你独特的需求选择一些元素,本节将展示如何实现这一点。我们构建了一些小脚本,展示了如何访问面、边和顶点以及如何处理这些对象的各个属性。
选择扭曲(非平面)四边形
扭曲 四边形,也称为蝴蝶结 四边形,有时在创建面时意外混淆顶点顺序时形成。在不太极端的情况下,它们可能在移动平面四边形的单个顶点时形成。这个小插图显示了这些在 3D 视图中可能看起来如何:

在 3D 视图中,右侧的扭曲面看起来并不异常,但渲染时它并不显示均匀的着色:

这两个对象都是平面,由一个面和四个顶点组成。左边的是一个蝴蝶结四边形。它的右边边旋转了整整 180 度,结果形成了一个难看的黑色三角形,我们可以看到扭曲面的背面。右边的平面在 3D 视图中没有明显的扭曲,尽管它的右上角顶点在 z 轴(我们的视线)上移动了相当的距离。然而,当渲染时,右平面的扭曲却非常明显。轻微扭曲的四边形的可见扭曲可以通过设置面的 smooth 属性来克服,这将沿面插值顶点法线,从而产生平滑的外观。当通过骨架建模或变形网格时,轻微扭曲的四边形几乎是不可避免的,它们是否导致可见问题取决于具体情况。通常,如果你能识别并选择它们,以便做出自己的判断,这会很有帮助。
可以通过检查构成四边形的三角形的法线是否指向同一方向来识别扭曲的四边形。一个平坦的四边形的三角形法线将指向与以下图示相同的方向:

而在一个扭曲的四边形中,这些法线并不平行:

这些三角形法线与顶点法线不同:顶点法线定义为共享顶点的所有面的法线的平均值,因此我们不得不自己计算这些三角形法线。这可以通过计算边向量的叉积来完成,即由每条边的两个端点定义的向量。在所显示的示例中,左边的三角形法线是通过计算边向量 1 → 0 和 1 → 2 的叉积得到的,右边的三角形是通过计算边向量 2 → 1 和 2 → 3 的叉积得到的。
我们是按顺时针还是逆时针遍历边没有关系,但我们必须在计算叉积时注意保持边顺序的一致性,因为符号将会反转。一旦我们有了三角形法线,我们可以通过验证一个向量的所有分量(x、y 和 z)与第二个向量的相应分量相比是否按相同的因子缩放来检查它们是否指向完全相同的方向。然而,为了给我们提供更多的灵活性,我们希望计算三角形法线之间的角度,并且只有当这个角度超过某个最小值时才选择面。我们不必自己设计这样的函数,因为 Blender.Mathutils 模块提供了 AngleBetweenVecs() 函数。
在一个四边形内可以构造四个不同的三角形,但不需要比较所有三角形法线——任何两个法线就足够了,因为移动四边形的一个顶点将改变四个可能三角形法线中的三个。
代码轮廓扭曲选择
带着所有这些信息,我们的工具轮廓将如下所示:
-
显示最小角度的弹出窗口。
-
验证活动对象是否为网格且处于编辑模式。
-
启用面选择模式。
-
对于所有面,检查面是否为四边形,如果是,则:
-
计算由顶点 0、1 和 2 定义的三角形法线
-
计算由顶点 1、2 和 3 定义的三角形法线
-
计算法线之间的角度
-
如果角度 > 最小角度,则选择面
-
这在以下代码中得到了实际检测和选择的体现(完整的脚本作为warpselect.py提供):
def warpselect(me,maxangle=5.0):
for face in me.faces:
if len(face.verts) == 4:
n1 = ( face.verts[0].co - face.verts[1].co ).cross(
face.verts[2].co - face.verts[1].co )
n2 = ( face.verts[1].co - face.verts[2].co ).cross(
face.verts[3].co - face.verts[2].co )
a = AngleBetweenVecs(n1,n2)
if a > maxangle :
face.sel = 1
如您所见,我们的轮廓几乎与代码一一对应。注意,AngleBetweenVecs()返回的角度是以度为单位,因此我们可以直接将其与也是以度为单位的最小角度maxangle进行比较。此外,我们不需要自己实现两个向量的叉积,因为 Blender 的Vector类已经包含了各种运算符。在我们能够调用此函数之前,我们必须注意一个重要的细节:为了选择面,面选择模式应该被启用。这可以通过以下方式完成:
selectmode = Blender.Mesh.Mode()
Blender.Mesh.Mode(selectmode | Blender.Mesh.SelectModes.FACE)
为了说明一个不太为人所知的事实,即选择模式并非不互斥,我们除了设置任何已选模式外,还设置了面选择模式,通过使用二进制或运算符(|)结合值来实现。在脚本结束时,我们恢复到之前激活的模式。
选择超锐利面
存在许多工具可以用于选择在某种程度上难以处理的面的选择。Blender 内置了选择面积过小或周长过短的面选择工具。然而,它缺少一个用于选择边形成角度比某个限制更锐利的面的工具。在某些建模任务中,如果我们能够选择这样的面,将会非常方便,因为它们通常很难操作,并且在应用次表面修改器或变形网格时可能会产生难看的伪影。
注意
注意,尽管名称如此,Blender 的选择****锐边工具(Ctrl + Alt + Shift + S)实际上做的是不同的事情;它选择那些恰好由两个面共享的边,这两个面的接触角度小于某个最小值,或者换句话说,选择相对平坦的面之间的边。
我们已经看到,Blender 的Mathutils模块有一个用于计算角度的函数,因此我们的代码非常简短,因为实际的工作是由下面显示的单个函数完成的。(完整的脚本作为sharpfaces.py提供。)
def sharpfaces(me,minimum_angle):
for face in me.faces:
n = len(face.verts)
edges = [face.verts[(i+1)%n].co - face.verts[i].co for i in range(n)]
for i in range(n):
a = AngleBetweenVecs(-edges[i],edges[(i+1)%n])
if a < minimum_angle :
face.sel = 1
break
注意,我们并不区分三角形或四边形,因为两者都可能具有通过锐角相连的边。前述代码中高亮的部分显示了一个细微的细节;每次我们计算两个边向量的角度时,我们都会反转其中一个,因为为了计算正确的角度,每个向量都应该起源于同一个顶点,而我们是将它们都计算为从一个顶点指向下一个顶点。
区别在下图中展示:

选择具有许多边的顶点
理想情况下,一个网格将包含只由四个顶点组成的面(这些面通常被称为四边形)并且大小相当均匀。这种配置在变形网格时是最优的,这在动画中通常是必要的。当然,三角形面(tris)本身并没有内在的错误,但通常最好避免它们,因为小的三角形面会破坏次表面修改器,导致它们出现难看的涟漪。

现在即使你有一个只由四边形组成的网格,一些顶点也是超过四个边的中心。这些顶点有时被称为极点,因此以下章节中脚本的名称。如果边的数量过多,比如说六个或更多(如前一个截图所示),这样的区域可能变得难以变形,并且对于模型师来说难以操作。在一个大而复杂的网格中,这些顶点可能难以定位,因此我们需要一个选择工具来选择这些顶点。
选择极点
为了选择具有特定步数的顶点,我们可以执行以下步骤:
-
检查活动对象是否为
Mesh。 -
检查我们是否处于对象模式。
-
显示一个弹出菜单以输入最小边数。
-
对于每个顶点:
-
遍历所有边,计算顶点的出现次数
-
如果计数大于或等于最小值,则选择顶点
-
这种方法简单直接。负责实际工作的函数如下所示(完整脚本名为poleselect1.py)。它紧密遵循我们的大纲。实际选择一个顶点是通过将顶点的sel属性赋值来实现的。注意,edge对象的v1和v2属性并不是我们网格verts属性的索引,而是指向MVert对象。这就是为什么我们需要检索index属性来比较的原因。
def poleselect1(me,n=5):
for v in me.verts:
n_edges=0
for e in me.edges:
if e.v1.index == v.index or e.v2.index == v.index:
n_edges+=1
if n_edges >= n:
v.sel = 1
break
再次选择极点
你可能已经注意到,我们遍历了每个顶点的边列表(在前面的代码中突出显示)。这可能在性能上代价高昂,而且这种成本还因为需要反复检索索引而加剧。是否有可能编写更高效的代码,同时仍然保持可读性?如果遵循以下策略,就可以做到:
-
检查活动对象是否为
Mesh。 -
检查我们是否处于对象模式。
-
显示一个弹出菜单以输入最小边数。
-
初始化一个字典,以顶点索引为索引,将包含边计数。
-
遍历所有边(更新两个引用顶点的计数)。
-
遍历字典中的项(如果计数大于或等于最小值,则选择顶点)。
通过使用这种策略,我们只需进行两次可能很长的迭代,代价是需要存储字典的内存(没有什么是免费的)。对于小网格,速度的提高可以忽略不计,但对于大网格,速度的提高可能相当可观(我在一个小型的 3000 个顶点的网格上测得速度提高了 1000 倍),而这些正是可能需要这种工具的网格类型。
我们改进的选择函数如下(完整的脚本名为poleselect.py)。首先注意import语句。我们将使用的字典被称为默认字典,由 Python 的 collections 模块提供。一个默认字典是一种在第一次引用缺失项时初始化它的字典。由于我们希望增加每个由边引用的顶点的计数,我们可以在事先为网格中的每个顶点初始化零值,或者在我们想要增加计数时检查顶点是否已经索引,如果没有,则初始化它。默认字典消除了事先初始化所有内容的需要,并允许使用非常易读的语法。
我们通过调用defaultdictionary()函数(在面向对象领域,一个通过函数的某些参数配置其行为的函数返回新对象被称为工厂)并传递一个int参数来创建我们的字典。该参数应该是一个不接受任何参数的函数。我们在这里使用的内置函数int()在无参数调用时将返回一个整数值零。每次我们使用一个不存在的键访问我们的字典时,都会创建一个新的项,其值将是我们的int()函数的返回值,即零。关键行是两个增加edgecount(以下代码的高亮部分)的行。我们可以用稍微不同的方式编写那个表达式来展示为什么我们需要默认字典:
edgecount[edge.v1.index] = edgecount[edge.v1.index] + 1
表达式右侧我们引用的字典项可能每次当我们第一次遇到一个顶点索引时可能还不存在。当然,我们可以在事先进行检查,但这会使代码的可读性大大降低。
from collections import defaultdict
def poleselect(me,n=5):
n_edges = defaultdict(int)
for e in me.edges:
n_edges[e.v1.index]+=1
n_edges[e.v2.index]+=1
for v in (v for v,c in n_edges.items() if c>=n ):
me.verts[v].sel=1
确定网格的体积
虽然 Blender 实际上不是一个 CAD 程序,但许多人用它来解决类似 CAD 的问题,如建筑可视化。Blender 能够导入许多类型的文件,包括主要 CAD 程序的文件,因此包括精确测量的技术模型永远不会是问题。
这些 CAD 程序通常提供各种工具来测量模型(的部件)的尺寸,然而,Blender 由于其本质,提供的这些工具非常有限。通过按 3D 视图窗口中的N键,可以检查对象的大小和位置。在编辑模式下,您可以启用显示边长、边角和面面积(请参阅按钮窗口编辑上下文(F9)中的网格工具更多面板),但这只是其中的一部分。
在我们需要某些特定测量且无法将模型导出到 CAD 工具的情况下,Python 可以克服这些限制。一个实际的例子是计算网格的体积。如今,许多公司提供通过 3D 打印技术将您的数字模型重新创建为现实世界物体的可能性。我得说,握在手中一个塑料或甚至金属的 Blender 模型复制品是一种相当特殊的感觉,它真的为 3D 增加了一个全新的维度。
现在模型 3D 打印的主要成本组成部分是所需使用的材料量。通常,您可以将模型设计为空心物体,以减少生产所需的材料,但反复上传模型的中间版本以让制造商的软件计算体积并给出报价是非常不方便的。因此,我们希望有一个可以以相当准确的方式计算网格体积的脚本。
计算网格体积的一种常见方法有时被称为测量员公式,因为它与测量员通过三角测量其表面来计算山丘或山脉体积的方式有关。
核心思想是将三角剖分的网格分成许多以 xy 平面为底座的柱体。
将三角形投影到 xy 平面上的表面积乘以三个顶点的平均 z 位置,然后给出这样一个柱体的体积。将这些体积相加,最终给出完整网格的体积(请参见下一图)。

有几点需要注意。首先,网格可能延伸到 xy 平面以下。如果我们从一个位于 xy 平面以下的面构建一个柱体,投影面积与 z 坐标平均值之积将是一个负数,因此我们必须取其相反数以得到体积。

其次,一个网格可能完全或部分位于 xy 平面之上。如果我们看一下前一张图中的例子,我们会看到物体有两个三角形对物体的体积有贡献,即顶部和底部的三角形(垂直三角形的投影面积为零,因此不会做出贡献)。由于顶部和底部面都位于 xy 平面之上,我们必须从由顶部面构建的体积中减去由底部面构建的柱体的体积。如果物体完全位于 xy 平面之下,情况则相反,我们必须从底部柱体的体积中减去顶部柱体的体积。
我们如何确定要做什么是由我们三角形的面对法线方向决定的。例如,如果一个三角形位于 xy 平面之上,但其面对法线指向下方(具有负的 z 分量),那么我们必须减去计算出的体积。因此,所有面对法线始终指向外部(在编辑模式下,选择所有面并按Ctrl + N)至关重要。
如果我们考虑所有四种可能性(面对法线向上或向下,面对位于 xy 平面之上或之下),我们可以为我们的函数写出以下大纲:
-
确保所有面对法线始终指向外部。
-
对于所有面:
-
计算面对法线向量的 z 分量Nz
-
计算平均 z 坐标和投影表面积的乘积P。
-
如果 Nz 为正:添加 P
-
如果 Nz 为负:减去 P
-
这个巧妙的算法适用于没有孔的简单物体,同样适用于包含孔的物体(例如环面),甚至空心物体(即完全包含在另一个物体中的物体),如下面的截图所示:

由于我们允许面积和 z 坐标的乘积为负,我们只需要检查面对法线的方向,以涵盖所有情况。
注意
注意,网格必须是封闭的和单形的:不应该有任何缺失的面,也不应该有任何不恰好共享两个面的边,例如内部面。

代码的重要部分在这里显示(完整的脚本名为volume.py):
def meshvolume(me):
volume = 0.0
for f in me.faces:
xy_area = Mathutils.TriangleArea(vec(f.v[0].co[:2]),vec(f.v[1].co[:2]),vec(f.v[2].co[:2]))
Nz = f.no[2]
avg_z = sum([f.v[i].co[2] for i in range(3)])/3.0
partial_volume = avg_z * xy_area
if Nz < 0: volume -= partial_volume
if Nz > 0: volume += partial_volume
return volume
突出的代码显示了我们是如何计算投影在 xy 平面上的三角形面积的。TriangleArea()将在给定二维点(xy 平面上的点)时计算二维三角形的面积。因此,我们不是传递顶点的完整坐标向量,而是将它们截断(即,我们丢弃 z 坐标)成两个分量向量。
在文本编辑器中运行脚本,或者在对象模式下从脚本菜单运行后,会弹出一个显示体积的窗口,单位是 Blender 单位。在运行脚本之前,请确保所有修改器都已应用,缩放和旋转已应用(Ctrl + A在对象模式下),网格已完全三角化(Ctrl + T在编辑模式下),并且通过检查非流形边来确保网格是流形的(Ctrl + Alt + Shift +M在边选择模式下)。流形****边是恰好由两个面共享的边。还要确保所有法线都指向正确的方向。应用修改器是必要的,以使网格封闭(如果它是镜像修改器)并使体积计算准确(如果它是子表面修改器)。
确定网格的质心
当我们在塑料或金属中打印三维物体时,一旦我们基于我们创建的网格创建了第一个玩具,可能会出现一个看似无害的问题;它的质心在哪里?如果我们的模型有腿但我们不希望它翻倒,它的质心最好在它的脚上,最好是尽可能低,以保持稳定性。这个图示显示了这一点:

一旦我们知道了如何确定网格的体积,我们就可以重用许多概念来编写一个脚本来确定质心位置。为了计算质心的位置,还需要两个额外的知识点:
-
在计算网格体积时,我们构建的投影体积的质心
-
如何将这些单个体积计算出的质心相加
所有这些都假设我们的网格的实体部分具有均匀的密度。网格可能具有任何形状,甚至可能是空心的,但假设实体部分具有均匀的密度。这对于 3D 打印机沉积的材料是有效的假设。
第一个问题涉及一点几何学:投影体积基本上是一个三角形柱体(或三角形棱柱体),顶部可能是一个斜三角形面。计算质心的方法可能如下:质心的 x 和 y 坐标是 xy 平面上投影三角形的中心 x 和 y 坐标——这些只是定义三角形面的三个点的 x 和 y 坐标的平均值。质心的 z 坐标位于我们投影柱体平均高度的一半。这是三角形面三个点的 z 坐标的平均值除以二。
第二个问题主要是常识:给定两个质量分别为 m1 和 m2,它们的质心分别位于 v1 和 v2,它们的组合质心是加权平均值。也就是说,质心与最重部件的质心成比例地更近。
注意
当然,对我们来说这是常识,但像阿基米德这样的人才能看到这实际上确实是常识。在发现这个“杠杆定律”(他称之为)之后,他没有大喊“我找到了”或光着身子到处跑,因此花了相当长的时间才引起人们的注意。
让我们把所有这些信息放入一个我们可以遵循的食谱中:
-
确保所有面的法线始终指向外侧。
-
对于所有面:
-
计算面法线向量的 z 分量 Nz
-
计算平均 z 坐标和投影表面积的产品 P
-
使用 x,y 为面的投影 x,y 坐标的平均值和 z(面的 z 坐标平均值)/2 来计算 CM(x, y, z)
-
如果 Nz 是正数:加上 P 乘以 CM
-
如果 Nz 是负数:减去 P 乘以 CM
-
从上面的概述中,很明显,计算质心与计算部分体积是相辅相成的,因此重新定义meshvolume()函数是有意义的:
def meshvolume(me):
volume = 0.0
cm = vec((0,0,0))
for f in me.faces:
xy_area = Mathutils.TriangleArea(vec(f.v[0].co[:2]),vec(f.v[1].co[:2]),vec(f.v[2].co[:2]))
Nz = f.no[2]
avg_z = sum([f.v[i].co[2] for i in range(3)])/3.0
partial_volume = avg_z * xy_area
if Nz < 0: volume -= partial_volume
if Nz > 0: volume += partial_volume
avg_x = sum([f.v[i].co[0] for i in range(3)])/3.0
avg_y = sum([f.v[i].co[1] for i in range(3)])/3.0
centroid = vec((avg_x,avg_y,avg_z/2))
if Nz < 0: cm -= partial_volume * centroid
if Nz > 0: cm += partial_volume * centroid
return volume,cm/volume
添加或更改的代码行被突出显示。
关于精度的几点说明
虽然我们大多数人是艺术家而不是工程师,但我们仍然可能想知道我们为网格体积或质心计算的数字有多准确。有两件事要考虑——我们算法的内在精度和计算精度。
内在 精度是我们考虑我们的模型由小多边形组成,这些多边形近似于某种想象中的形状时所指的。在进行有机建模时,这几乎无关紧要;如果我们的模型看起来不错,那就是好的。然而,如果我们试图通过多边形模型(比如 uv 球体或二十面体)来近似某种理想形状,例如球体,那么计算出的体积和理想球体的已知体积之间将存在差异。我们可以通过增加细分数量(或相当于多边形的大小)来提高这种近似的精度,但我们永远无法完全消除这种差异,并且用于计算体积的算法无法改变这一点。
计算 精度有几个方面。首先,是我们计算时使用的数字的精度。在 Blender 运行的大多数平台上,计算使用的是双精度浮点数。这相当于大约 17 位精度,我们无法提高这一点。幸运的是,这已经足够我们使用了。
然后是我们的算法的准确性。当你查看代码时,你会看到我们在添加和乘以可能巨大的值,因为典型的高分辨率模型可能包含超过十万甚至一百万个面。对于每个面,我们计算投影柱的体积,并将所有这些体积相加(或相减)。问题是这些体积可能相差很大,不仅因为面的面积可能不同,而且尤其是因为几乎垂直的面投影面积与几乎水平的面相比非常小。
现在如果我们用有限精度的计算将一个非常大的数和一个非常小的数相加,我们会丢失小的数。例如,如果我们的精度限制为三个有效数字,则将 0.001 和 0.0001 相加将得到 0.001,丢失了小数的影响。现在我们的精度要好得多(大约 17 位),但我们添加的数字要多得多。然而,如果我们通过使用引用的算法之一实现 volume() 函数,差异永远不会超过百万分之一,所以只要我们不打算用 Blender 做核科学,就没有必要烦恼。(对于那些这样做的人,脚本中提供了一个替代函数 volume2()。不过,仍然要小心,确保你知道你在做什么)。
注意
Python 能够处理可能无限大和精确度的数字,但这比进行正常的浮点计算要慢得多。Mathutils 中提供的函数和类主要是用 C 语言编写的,以提高速度并限制为双精度浮点数。请参阅 code.activestate.com/recipes/393090/ code.activestate.com/recipes/298339/ 或 O'Reilly 出版的《Python 烹饪书》第二版的第 18.5 节,了解一些其他技术和数学背景。
向日葵生长——亲子关系和分组对象
创建复杂的对象组装很容易自动化,但我们希望为最终用户提供选择所有这些相关对象并一起移动它们的方法。本节展示了我们如何通过创建分组和将对象相互关联来实现这一点。结果你会得到一束漂亮的向日葵。

分组
分组是为了使同时选择或操作多个对象变得更加容易。有时这种行为是更大计划的一部分。例如,骨架是一个骨骼的集合,但然后这些集合具有非常具体的关系(骨架中的骨骼彼此之间有精确的关系)。
在许多情况下,我们希望识别一组对象属于同一集合,即使它们之间没有特定的关系。Blender 提供了两种类型的组来帮助我们定义它们松散的关系:对象组(或简称组)用于命名对象集合,顶点组用于命名网格对象内的顶点集合。
对象组允许我们选择一组原本无关的对象,我们将它们添加到组中(例如,我们可以将网格、骨架和一堆空对象一起分组)。组关系与父子关系不同。组仅仅允许我们选择对象,但父对象移动时,被父化的对象也会移动。定义和操作组的功能由Group模块及其同名类(一个组就像另一个 Blender 对象,但包含其他对象的引用列表,但不幸的是不包括其他组)。例如,你可以像添加Lamp或Mesh一样添加一个组从外部的.blend文件。以下表格列出了一些常用的组操作(有关附加功能,请参阅 Blender API 文档中的Blender.Group模块):
| Operation | 动作 |
|---|---|
group=Group.New(name='aGroupName') |
创建一个新的组 |
group=Group.Get(name='aGroupName') |
通过名称获取组的引用 |
顶点组是一种方便的方式来识别相关顶点的集合(例如玩具模型中的耳朵或腿),但它们的应用远不止于简单的选择。它们可以用来确定骨骼变形的影响,或者识别粒子系统的发射区域等。在下一章中,我们将重点关注顶点组。
父子关系
在现实生活中,父子关系有时可能很难,但在 Blender 中却相当简单,尽管有时有令人困惑的选项可供选择。可以将对象绑定到另一个对象、骨架中的一个单一骨骼,或者Mesh对象中的一个或三个顶点上。以下表格显示了相关的方法(有关附加功能,请参阅 Blender API 中的Blender.Object)。
| Operator | 动作 |
|---|---|
| parent.makeParent([child1, child2, child3]) | 将父-子关系应用到父对象上 |
| parentmesh.makeParentVertex([child1,child2,child3],vertexindex1) | 将父-子关系应用到顶点上 |
| parentmesh.makeParentVertex([child1,child2,child3],vertexindex1,vertexindex2,vertexindex3) | 将父-子关系应用到三个顶点上 |
从种子中生长向日葵
当我们编写一个创建向日葵模型(梵高如果看到这个“向日葵”可能也会割掉他的另一只耳朵,但话又说回来,他的方式完全是另一种看待方式)的脚本时,我们可以充分利用所有这些信息。我们将创建的单个向日葵由茎和花头组成。向日葵的花头由小花朵组成,一旦受精就会变成种子,以及一圈大花瓣。(我知道,任何植物学家都会对我的语言感到不适。这些小花朵被称为“花盘小花”——但花蕾不就是一个“小花朵”吗?而边缘上的那些是“辐射小花”。)我们的花头将有种子,每个种子都是一个独立的 Mesh 对象,它将成为花头网格的顶点父对象。
我们希望我们的种子不仅随着种子头移动,而且要跟随任何局部曲率并相对于头表面垂直定位,这样我们就可以,例如,通过比例编辑扭曲头网格,所有附加的种子都会跟随。为了实现这一点,我们使用顶点父化的三个顶点变体。
通过将一个对象与网格的三个不同顶点关联,该对象将跟随这些顶点的位置并相对于法线进行定位(参见以下插图):

我们不需要连接所有这些顶点三元组,因为花头网格本身将不会被渲染(它将被种子完全覆盖)。尽管如此,我们确实在每对顶点三元组之间定义了一个面;否则,在 编辑 模式下,建模者将很难看到花头网格。

花瓣是独立的对象,按照常规方式附加到花头网格上,因为它们不需要遵循花头网格的任何曲率,只需要其位置和旋转。反过来,花头被附加到茎上,这样我们就可以通过移动茎来移动整个组件。
最后,我们将所有单个对象分配到单个组中。这样,我们可以一次性选择所有内容,并使我们能够将一个或多个从外部文件链接或附加的向日葵作为一个单一实体。
复制与实例化
我们说过,所有的种子和花瓣都是独立的对象,但用实例化它们可能更有意义(在 Blender 中称为创建链接副本)。因为我们所建模的所有种子和花瓣都是相同的,我们可以引用相同的网格数据,只需根据需要更改对象的位置、旋转或缩放——可能节省相当多的内存。在交互式使用 Blender 时,我们可以通过按 Alt + D(而不是 Shift + D 用于常规复制)来实例化一个对象。在我们的脚本中,我们简单地定义一个新的对象,并通过在调用 Object.New() 时传递对同一网格的引用来将其指向相同的 Mesh 对象。
向日葵的生长
让我们看看创建向日葵的主要部分脚本(完整的脚本作为 sunflower.py 提供)。第一步是计算种子的位置:
def sunflower(scene,nseeds=100,npetals=50):
pos = kernelpositions(nseeds)
从这些位置,我们创建了头部、顶点和面,可以将内核附加到这些面上,并将它们组装成头部网格(以下代码中的高亮部分):
headverts=pos2verts(pos)
faces=[(v,v+1,v+2) for v in range(0,len(headverts),3)]
head=Tools.addmeshobject(scene,headverts,faces,name='head')
下一步是创建内核的基网格,并创建引用此网格的对象(以下代码中的高亮部分):
kernelverts,kernelfaces=kernel(radius=1.5,scale=(1.0,1.0,0.3))
kernelmesh = Tools.newmesh(kernelverts,kernelfaces,name='kernel')
kernels = [Tools.addmeshduplicate(scene,kernelmesh,name='kernel')for i in range(nseeds)]
然后,每个内核被分配一个合适的位置,并附加到花头网格中的适当顶点上(以下代码中的高亮部分):
for i in range(nseeds):
loc = Tools.center(head.data.verts[i*3:(i+1)*3])
kernels[i].setLocation(loc)
head.makeParentVertex([kernels[i]],tuple([v.index for v in head.data.verts[i*3:(i+1)*3]]))
接下来,我们创建一个花瓣网格,并将该网格的副本沿着花头的边缘排列(以下代码中的高亮部分):
petalverts,petalfaces=petal((2.0,1.0,1.0))
petalmesh = Tools.newmesh(petalverts,petalfaces,name='petal')
r = sqrt(nseeds)
petals = [Tools.addmeshduplicate(scene,petalmesh,name='petal') for i in range(npetals)]
每个花瓣都沿着边缘定位和旋转,并附加到头部(以下代码中的高亮部分):
for i,p in enumerate(petals):
a=float(i)*2*pi/npetals
p.setLocation(r*cos(a),r*sin(a),0)
e=p.getEuler('localspace')
e.z=a
p.setEuler(e)
head.makeParent(petals)
最后,我们创建一个茎网格和对象,并将头部附加到茎上。这样,整个花朵就可以通过移动茎来移动:
# add stalk (parent head to stalk)
stalkverts,stalkfaces=stalk()
stalkob = Tools.addmeshobject(scene,stalkverts,stalkfaces,name='stalk')
stalkob.makeParent([head])
剩下的只是将内核和花瓣分组到单独的组中(高亮显示),然后在一个总的向日葵组中包含所有部分,以便于参考:
kernelgroup = Blender.Group.New('kernels')
kernelgroup.objects=kernels
petalgroup = Blender.Group.New('petals')
petalgroup.objects=petals
all = Blender.Group.New('sunflower')
all.objects=sum([kernels,petals],[head,stalkob])
在代码中使用的 addmeshduplicate() 函数是在以下方式实现的:Tools 模块中。
def addmeshduplicate(scn,me,name=None):
ob=scn.objects.new(me)
if name : ob.setName(name)
scn.objects.active=ob
me.remDoubles(0.001)
me.recalcNormals()
for f in me.faces: f.smooth = 1
me.update()
Blender.Window.RedrawAll()
return ob
给定一个场景、一个网格和一个对象名称(可选),它会在场景中添加一个新的对象。作为参数传递的 Mesh 对象可能被反复使用来创建引用相同网格的新对象。
新创建的对象将被自动选中,但不会变为活动状态,因此下一步是使新创建的对象变为活动状态(前述代码中的高亮部分)。这不是必需的,但可能对用户来说更方便,就像接下来的两个动作一样:确保所有面的法线方向一致向外,并删除任何非常接近的顶点。这些最后两个动作只能在嵌入在对象中的网格上执行。
此外,为了方便起见,我们为所有面设置了 smooth 属性,以便在渲染时获得更平滑的图像。最后,我们更新了这个网格的显示列表,并通知所有 Blender 窗口已发生更改。
小贴士
稍微偏离一下主题,或者为什么兔子与向日葵有关。
你可能会注意到,我们以一种奇特的方式排列了种子。这种螺旋,其中螺旋上的后续位置是通过遵循所谓的黄金比例来间隔的,被称为费马螺旋。当花蕾或种子在中间形成并向外推时,这种螺旋自然地导致许多种子头,从而实现高度有效的包装。
从上方看,种子的排列似乎也遵循左右转弯的曲线。这些曲线的数量通常是一对来自斐波那契****序列[ 1 1 2 3 5 8 13 21 …],当这些数字变大时,这样一对数字的比例往往会收敛到黄金比例。 (在我们的种子头部的两个插图下,我们可以辨别出 13 个逆时针螺旋和 21 个顺时针螺旋。) 斐波那契发明了这个序列,试图模拟兔子的种群增长。有关向日葵(以及可能还有兔子)的更多信息,请参阅en.wikipedia.org/wiki/Sunflower。

概述
在本章中,我们看到了如何创建复杂对象,以及如何通过提供一个记住先前选择的图形用户界面,使最终用户配置这些对象的任务变得简单。我们了解到,也可以将 Blender 作为命令行工具来自动化常见任务。
我们还学习了如何创建对象之间的父子关系,并在编辑网格方面迈出了第一步。具体来说,我们看到了如何:
-
创建可配置的网格对象
-
设计图形用户界面
-
让你的脚本存储用户选择以便以后重用
-
在网格中选择顶点和面
-
将一个对象关联到另一个对象
-
创建组
-
修改网格
-
从命令行运行 Blender 并在后台渲染
-
处理命令行参数
在下一章中,我们将看到如何将顶点组和材质分配给我们的网格。
第三章:顶点组和材料
当顶点数量很大时,复杂的网格可能难以处理。在本章中,我们将探讨如何通过定义顶点组来标记顶点集合,从而让最终用户的生活更加轻松。我们还将探索顶点组的许多用途,包括它们在骨架和修改器中的应用,以及我们将探讨将不同材质应用于网格不同部分的方法。
在本章中,我们将学习如何:
-
定义顶点组
-
将顶点分配给顶点组
-
将材质分配给面
-
将顶点颜色分配给顶点
-
设置边属性
-
添加修改器
-
皮肤骨骼

顶点组
顶点 组是组织网格内顶点集合的一种方式。一个网格可以有任意数量的顶点组,网格中的任何顶点都可以是多个顶点组的成员,或者根本不属于任何顶点组。一个新创建的Mesh对象没有定义任何顶点组。
在其基本形式中,顶点组是识别复杂网格中不同部分的有价值工具。通过将顶点分配给顶点组,建模者最终为人们,如绑定者或为模型贴图的人,提供了轻松识别和选择他们想要工作的模型部分的方法。
尽管顶点组的使用远不止简单的识别。许多网格修改器将它们的影响限制在指定的顶点组中,并且可以通过将每个骨骼的影响链接到单个顶点组来配置骨架以变形网格。我们将在稍后看到这方面的例子。
一个顶点组不仅仅是顶点的集合。顶点组中的每个顶点都可能有一个关联的权重(介于零和一之间),许多修改器使用它来微调它们的影响。一个顶点在其所属的每个顶点组中可能具有不同的权重。
我们用creepycrawlies.py创建的虫子是一个相当复杂的网格的极好例子,它具有许多不同的部分,定义顶点组将极大地从中受益。不仅可以通过名称选择部分,例如头部,而且如果我们要为模型绑定,也会使我们的工作更加容易。
我们创建顶点组的主要工具是以下表中列出的Mesh对象的方法:
| 方法 | 操作 | 备注 |
|---|---|---|
addVertGroup(group) |
添加一个新的空顶点组。 | |
assignVertsToGroup(group,vertices,weight,mode) |
将顶点索引列表添加到具有给定权重的现有顶点组中。 | 模式确定当顶点已经是顶点组的成员时应该做什么。请参阅正文以获取详细信息。 |
getVertsFromGroup(group,weightsFlag=0,vertices) |
返回一个顶点索引列表(默认)或一个包含(索引,权重)元组的列表(如果weightsFlag等于1)。如果指定了顶点列表,则只返回该组中且在给定列表中的顶点。 |
|
removeVertsFromGroup(group,vertices) |
从现有的顶点组中删除顶点列表。如果未指定列表,则删除所有顶点。 | |
renameVertGroup(groupName, newName) |
重命名一个顶点组。 | |
getVertGroupNames() |
返回所有顶点组名称的列表。 | |
removeVertGroup(group) |
删除一个顶点组。 | 不会删除实际的顶点。 |
在这里需要掌握的重要概念是,创建顶点组和将顶点分配给它是两个不同的操作。通过调用您的Mesh对象的addVertGroup()方法创建一个新的空顶点组。它接受一个字符串参数,该参数将是顶点组的名称。如果已经存在具有相同名称的顶点组,则名称将添加一个数字后缀以防止名称冲突,例如:TailSegment可能变为TailSegment.001。
向现有的顶点组添加顶点是通过调用您的网格的assignVertsToGroup()方法来完成的。此方法将接受四个强制参数——要分配顶点的顶点组名称、顶点索引列表、权重和一个分配模式。如果顶点组不存在,或者其中一个顶点索引指向一个不存在的顶点,则会引发异常。
权重必须是一个介于 0.0 和 1.0 之间的值;任何大于 1.0 的权重都会被限制为 1.0。小于或等于 0.0 的权重将从一个顶点组中删除顶点。如果您想为同一顶点组中的顶点分配不同的权重,您必须通过单独调用assignVertsToGroup()方法来分配它们。
分配模式有三种:ADD、REPLACE和SUBTRACT。ADD会将新顶点添加到顶点组中,并将给定的权重与它们关联。如果列表中的任何顶点已经存在,它们将获得附加的权重。REPLACE将替换列表中索引关联的权重,如果它们是顶点组的成员,否则不执行任何操作。SUBTRACT将尝试从列表中的顶点减去权重,如果它们不是顶点组的成员,则不执行任何操作。在将完全新的顶点组添加到网格时,通常使用ADD模式。
一个重要的问题
在我们的第一个示例中,我们将向一个现有的网格对象添加两个新的顶点组——一个将包含所有具有正 x 坐标的顶点,另一个将包含具有负 x 坐标的顶点。我们将分别将这些组命名为右和左。
此外,我们还将根据每个顶点与其对象中心的距离为其分配一个权重,距离中心越远的顶点权重越大。
代码概要:leftright.py
概括地说,我们将采取以下步骤:
-
获取活动对象。
-
验证它是一个网格并获取网格数据。
-
向对象添加两个新的顶点组——Left 和 Right。
-
对于网格中的所有顶点:
-
计算权重
-
如果 x 坐标 > 0:
-
将顶点索引和权重添加到顶点组 right
-
如果 x 坐标 < 0:
-
将顶点索引和权重添加到顶点组 left
-
为了确保新的顶点组为空,我们检查该组是否已存在,并在必要时将其删除。此检查在代码中突出显示:
def leftright(me,maximum=1.0):
center=vec(0,0,0)
left =[]
right=[]
for v in me.verts:
weight = (v.co-center).length/maximum
if v.co.x > 0.0 :
right.append((v.index, weight))
elif v.co.x > 0.0 :
left.append((v.index, weight))
return left,right
if __name__ == "__main__":
try:
ob = Blender.Scene.GetCurrent().objects.active
me = ob.getData(mesh=True)
vgroups = me.getVertGroupNames()
if 'Left' in vgroups:
me.removeVertsFromGroup('Left')
else:
me.addVertGroup('Left')
if 'Right' in vgroups:
me.removeVertsFromGroup('Right')
else:
me.addVertGroup('Right')
left,right = leftright(me,vec(ob.getSize()).length)
for v,w in left:
me.assignVertsToGroup('Left',[v],w,Blender.Mesh.AssignModes.ADD)
for v,w in right:
me.assignVertsToGroup('Right',[v],w,Blender.Mesh.AssignModes.ADD)
Blender.Window.Redraw()
except Exception as e:
Blender.Draw.PupMenu('Error%t|'+str(e)[:80])
完整脚本作为 leftright.py 提供。计算权重的公式可能需要一些解释:为了将最大权重 1.0 分配给位于对象中心最远处的点,我们必须按可能的最大距离进行缩放。我们可以遍历所有顶点以确定最大值,但在这里我们选择通过尺寸的均方根来近似这个最大值。这将夸大最大距离,因此分配给任何顶点的最大权重可能小于 1.0。然而,获取尺寸比计算大型网格的确切最大值要快得多。此外,请注意,我们计算对象中心到距离(从网格中顶点的角度来看,对象中心始终在 (0, 0, 0))。
这可能与用户感知的网格中心完全不同。(在 Blender 中,对象中心通常以一个粉红色点显示,并且可以通过选择 对象 | 变换 | 中心新 来改变,使其位于所有顶点的平均位置。)
网格的结果权重可能如下所示:

修改器
修改器 是以非破坏性方式更改网格的工具,并且可以交互式调整。其他对象也可能有修改器:例如 Text3d、Metaballs 和 Curves。这些对象可以表示为网格,因此也可以进行修改。但并非所有修改器都可以与这些对象关联。如果需要,可以通过 应用 修改器的效果使其永久化。Blender 提供了从次表面修改器到各种变形修改器的一系列修改器。表中显示了可用的修改器列表:
| 修改器 | 顶点组影响 | 备注 |
|---|---|---|
| 位移 | 是 | |
| 曲线 | 是 | |
| 分解 | 是 | |
| 网格 | 是 | |
| 遮罩 | 是 | |
| 网格变形 | 是 | |
| 收缩包裹 | 是 | |
| 简单变形 | 是 | |
| 平滑 | 是 | |
| 波浪 | 是 | |
| 数组 | 否 | |
| 倒角 | 否 | |
| 并集 | 否 | |
| 构建 | 否 | |
| 投影 | 否 | |
| 简化 | 否 | |
| 边分割 | 否 | |
| 镜像 | 否 | |
| subsurface | no | |
| uvproject | no | |
| Particle system | yes | 许多参数受不同顶点组的影响 |
| armature | yes | 每个骨骼可能被限制仅影响单个顶点组 |
许多修改器可以被设置为仅将它们的影响限制在特定的顶点组上,而一些修改器是特殊的。粒子系统被认为是一个修改器,尽管通常粒子系统是通过它们自己的工具集进行管理的。此外,它与顶点组的关系在某种程度上是相反的;它不是将影响限制在顶点组内的顶点上,而是顶点组的顶点权重可能影响粒子系统的各种参数,例如粒子的发射密度和速度。我们将在 飞溅火花 部分看到一个例子。
骨骼修改器也是有些特殊的,因为它们不会将它们的影响限制在单个顶点组上。然而,它们可以被配置为将每个单独的骨骼的影响限制在特定的顶点组上,正如我们将在 骨骼 部分中考察的那样。
从 Python 程序员的角度来看,修改器列表是对象的一个属性(即,不是底层网格的属性)。引用相同网格的对象可能有不同的修改器。此列表包含 Modifier 对象,并且可以添加到或从列表中删除这些对象,并且可以移动列表中的单个修改器上下。在某些情况下,修改器的顺序很重要。例如,在镜像修改器之后添加子表面修改器可能与在子表面修改器之前添加镜像修改器看起来不同。
Modifier 对象有一个类型和一个名称(最初代表其类型,但它可能被设置为更合适的内容)。类型是 Modifier.Types 常量列表中的一个类型。每个修改器对象可能有许多设置,这些设置通过在 Modifier.Settings 中定义的键进行索引。并非所有设置都适用于所有类型。
如果我们有两个对象,一个名为 Target 的网格对象和一个名为 Deformer 的晶格对象,并且我们希望将 Deformer 对象作为晶格修改器关联到 Target 对象,以下代码片段将完成这项任务:
import Blender
from Blender import Modifier
target = Blender.Object.Get('Target')
deformer= Blender.Object.Get('Deformer')
mod = target.modifiers.append(Modifier.Types.LATTICE)
mod[Modifier.Settings.OBJECT] = deformer
target.makeDisplayList()
Blender.Window.RedrawAll()
如果 Target 对象有一个名为 Right 的顶点组,该顶点组包含 Target 对象右半部分的顶点,我们可以通过更改 VERTGROUP 属性来限制修改器的影响。我们的片段将变为以下内容(添加的行已突出显示):
import Blender
from Blender import Modifier
target = Blender.Object.Get('Target')
deformer= Blender.Object.Get('Deformer')
mod = target.modifiers.append(Modifier.Types.LATTICE)
mod[Modifier.Settings.OBJECT] = deformer
mod[Modifier.Settings.VERTGROUP] = 'Right'
target.makeDisplayList()
Blender.Window.RedrawAll()

雕刻
考虑以下问题:给定一些文本,我们希望将此文本渲染为表面上的凹槽,就像它被雕刻出来一样。这并不像看起来那么简单。当然,创建一个文本对象很简单,但为了操纵这个文本,我们希望将这个文本对象转换为网格。Blender GUI 在对象菜单中提供了这种可能性,但奇怪的是,Blender API 并没有提供等效的功能。因此,我们的第一个障碍就是将文本对象转换为网格。
我们必须解决的第二个问题是,如何将一组顶点或边挤出,以在表面上测量凹槽。同样,Blender API 中没有这个功能,所以我们必须自己将其添加到我们的工具包中。
最后一个问题更为微妙。如果我们设法创建了一些凹槽,我们可能希望使边缘不那么尖锐,因为现实生活中的东西没有完美的尖锐边缘。有各种方法可以做到这一点,但许多方法都需要在我们的网格中添加修改器。一个 斜面 修改器 可能足以去除尖锐的边缘,但我们可能还想将次表面修改器添加到整个网格中。在这里,我们遇到了一个问题,当填充文本字符之间的间隙时,我们很可能遇到许多狭窄的三角形。这些三角形破坏了我们的次表面修改器的外观,如下面的图所示:

以下两点可能有助于减轻这个问题。一是给雕刻文本的边缘添加折痕权重,这样在计算次表面时,这些边缘的权重会比默认值更大。这可能有所帮助,但也可能违背修改器的目的,因为它使这些边缘更尖锐。以下图显示了结果:更好,但仍然看起来不太好。

另一种方法是,在雕刻文本的外侧添加一个额外的边环。这将围绕文本添加一个四边形面的环,使得文本周围的次表面行为表现得更好,如下所示。在我们的最终实现中,我们应用了这两种解决方案,但首先我们一次解决一个问题。

将 Text3d 对象转换为网格
Text3d 对象基本上是一个带有一些额外参数的曲线。它所引用的数据块是一个 Blender Curve 对象,一旦我们知道了如何访问构成我们文本中每个字符的曲线的各个部分,我们就可以将这些曲线转换为顶点和边。所有相关功能都可以在 Blender.Curve 和 Blender.Geometry 模块中找到。
注意
在 Blender 中,Text3d 对象和 Curve 对象之间的关系比正文中所描述的要微妙和复杂得多。Text3d 对象是 Curve 对象的一个特殊版本,类似于面向对象术语中的子类。然而,在 Blender API 中,Text3d 对象不是 Curve 的子类,而且同一对象实例上也没有额外的属性。听起来很复杂?确实如此。那么如何检索所有属性呢?技巧在于你可以使用 Text3d 对象的名称来获取其关联的 Curve 对象,如下面的小示例所示:
txt = ob.getData()
curve = Blender.Curve.Get(txt.getName())
现在,我们可以使用 txt 来访问 Text3d 特定的信息(例如,txt.setText('foo'))和 curve 来访问 Curve 特定的信息(例如,curve.getNumCurves())。
Blender 的 Curve 对象由多个 CurNurb 对象组成,这些对象代表曲线的各个部分。通常,一个文本字符由一个或两个曲线段组成。例如,小写字母 e 由一个外部的曲线段和一个小的内部曲线段组成。CurNurb 对象反过来又由定义曲线段的多个 节点 或 控制点 组成。在这些情况下,节点始终是 BezTriple 对象,而 Blender 的 Geometry 模块为我们提供了 BezierInterp() 函数,该函数返回两个点之间插值的坐标列表。这些点和曲线在这些点处的方向(通常称为 手柄)可以从 BezTriple 对象中获取。生成的代码看起来是这样的(完整的代码是我们工具包中 Tools.py 部分的一部分):
import Blender
from Blender.Geometry import BezierInterp as interpolate
from Blender.Mathutils import Vector as vec
def curve2mesh(c):
vlists=[]
for cn in c:
npoints = len(cn)
points=[]
first=True
for segment in range(npoints-1):
a=cn[segment].vec
b=cn[segment+1].vec
lastpoints = interpolate(vec(a[1]),vec(a[2]),vec(b[0]),vec(b[1]),6)
if first:
first = False
points.append(lastpoints[0])
points.extend(lastpoints[1:])
if cn.isCyclic():
a=cn[-1].vec
b=cn[0].vec
lastpoints=interpolate(vec(a[1]),vec(a[2]),vec(b[0]),vec(b[1]),6)
points.extend(lastpoints[:-2])
vlists.append(points)
return vlists
突出的线条显示了两个重要的方面。第一个显示了实际的插值。我们将名称相当不自然的 BezierInterp() 函数重命名为 interpolate(),它接受五个参数。前四个参数来自我们正在插值之间的两个 BezTriple 对象。每个 BezTriple 对象都可以作为一个包含三个向量的列表访问——进入的手柄、点的位置和出去的手柄(参见下一图)。我们传递第一个点的位置和它的出去手柄以及第二个点的位置和它的进入手柄。第五个参数是我们希望 interpolate() 函数返回的点数。

第二条高亮行处理闭合曲线,即第一条和最后一点相连的曲线。这是所有在文本中形成字符的曲线的情况。该函数返回一个列表的列表。每个列表包含每个曲线的所有插值点(x、y、z 坐标的元组)。请注意,一些字符由多个曲线组成。例如,许多字体中的小写字母 e 或字母 o 由两个曲线组成,一个定义字母的轮廓,另一个定义内部。例如,包含文本 Foo 的 Text3d 对象将返回五个列表的列表——第一个将包含定义大写字母 F 的顶点,第二个和第三个将包含组成小写字母 o 的两个曲线的顶点,同样第四个和第五个也是如此。
拉伸边环
拉伸是我们复制顶点(以及可能连接它们的边)并将它们移动到某个方向的过程,之后我们通过新边将这些复制的顶点连接到它们的原点,并通过在旧顶点和新顶点之间创建新面来完成操作。我们需要它来将文本的轮廓下沉,以创建具有垂直壁的凹槽。Tools.py 中的 extrude_selected_edges() 函数接受一个网格和一个向量作为参数,并将网格中选定边的顶点沿向量方向拉伸,添加任何必要的新的边和面。因为技术是早期看到的东西的扩展,所以这里没有显示代码。
扩展边环
如果我们有一个形成闭合曲线(或多个)的边列表来定义一个字符,我们希望用额外的边环包围这些边,以在最终用户可能关联到我们的网格的任何次表面修改器中创建更好的“流动”。如果我们不得不在 3D 中计算这个,这将是一个相当复杂的过程,但幸运的是,我们转换后的角色所有顶点都在 xy 平面上(这是因为新实例化的Text3d对象中的所有字符都位于 xy 平面上)。

在仅仅两个维度中,这是一个相当容易解决的问题。对于我们的边环上的每一个点,我们确定顶点法线的方向。顶点法线是分割我们正在考虑的点共享的两条边之间的角度的线。如果两条边是共线的(或几乎是),我们将顶点法线视为垂直于其中一条边的线。要在新边环上创建的点将位于这条法线的某个位置。为了确定我们是否需要沿着这条法线向外或向内移动,我们只需尝试一个方向,并检查新位置是否在我们的字符轮廓内。如果是这样,我们就反转方向。
仍然有一个问题需要解决,一个字符可能由多个曲线组成。如果我们想在这样一个字符周围绘制额外的边环,这样的边环应该位于字符轮廓之外但任何内部曲线之内。换句话说,如果我们构造一个新的边环,我们必须知道曲线是否位于另一个曲线内。如果是这样,它不是轮廓,新的边环应该构造在曲线内。因此,我们的expand()函数(在下一个片段中显示,完整代码是Tools.py的一部分)接受一个额外的可选参数plist,它是一个包含定义要检查的额外多边形的MVert对象的列表的列表。如果我们想要扩展的曲线的第一个点位于这些额外曲线中的任何一个,我们假设我们正在扩展的曲线是一个内部****曲线。(如果内部曲线会在某个点与外部曲线交叉,这将是不正确的,但对于定义字体中字符的曲线来说,这种情况永远不会发生。)
def expand(me,loop,offset=0.05,plist=[]):
ov = [me.verts[i] for i in verts_from_edgeloop(loop)]
inside=False
for polygon in plist:
if in_polygon(loop[0].v1.co,polygon):
inside=True
break # we don't deal with multiple inclusions
n=len(ov)
points=[]
for i in range(n):
va = (ov[i].co-ov[(i+1)%n].co).normalize()
vb = (ov[i].co-ov[(i-1)%n].co).normalize()
cosa=abs(vec(va).dot(vb))
if cosa>0.99999 : # almost colinear
c = vec(va[1],va[0],va[2])
else:
c = va+vb
l = offset/c.length
p = ov[i].co+l*c
if in_polygon(p,ov) != inside:
p = ov[i].co-l*c
print i,ov[i].co,va,vb,c,l,cosa,p
points.append(p)
return points
高亮代码调用一个函数(在Tools.py中提供),该函数接受形成一个边环的边列表,并返回一个排序后的顶点列表。这是必要的,因为我们的in_polygon()函数接受一个顶点列表而不是边,并假设这个列表是排序的,即相邻顶点形成不交叉的边。
为了确定一个点是否在由顶点列表定义的闭合多边形内部,我们计算一条线(通常称为射线)穿过的边的数量,这条线从点开始并延伸到无穷大。如果穿过的边的数量是奇数,则点位于多边形内部;如果是偶数,则位于多边形外部。以下图示说明了这个概念:

这里显示的in_polygon()函数是Tools.py的一部分。它接受一个点(一个Vector)和一个顶点列表(MVert对象),并返回True或False。请注意,多边形中点或顶点的任何 z 坐标都被忽略。
from Blender.Geometry import LineIntersect2D
from Blender.Mathutils import Vector as vec
def in_polygon(p,polygon):
intersections = 0
n = len(polygon)
if n<3 : return False
for i in range(n):
if LineIntersect2D (p,vec(1.0,0.0,0.0),polygon[i].co,polygon[(i+1)%n].co):
intersections+=1
return intersections % 2 == 1
重的劳动是在高亮行中由Blender.Geometry模块中可用的LineIntersect2D()函数完成的。return语句中的modulo(%)操作是一种确定遇到的交点数是否为奇数的方法。
将所有这些放在一起:Engrave.py
带着在前几节中开发的全部辅助函数,我们可以列出为了雕刻文本我们需要执行的步骤:
-
显示一个弹出窗口以输入要雕刻的字符串。
-
检查活动对象是否是网格并且有面被选中。
-
创建一个
Text3d对象。 -
转换为网格,并添加适当的顶点组。
-
为字符添加额外的边环。
-
向下挤出原始字符。
-
填充挤出字符的底部。
-
在文本周围添加一个“卷轴”(一个矩形)。
-
填充卷轴和字符之间的空间。
-
添加一个子表面修改器。
-
在
TextTop和TextBottom顶点组中包含的边缘上设置折痕值。
我们最终的脚本几乎遵循这个大纲,并使用了我们在本章早期开发的工具。我们在这里展示最相关的部分(完整的脚本作为engrave.py提供)。我们首先将Text3d对象(以下代码中的c)转换为包含每个文本曲线段顶点位置的列表,并在场景中添加一个新的空Mesh对象和一些空顶点组:
vlist = curve2mesh(c)
me = Blender.Mesh.New('Mesh')
ob = Blender.Scene.GetCurrent().objects.new(me,'Mesh')
me.addVertGroup('TextTop')
me.addVertGroup('TextBottom')
me.addVertGroup('Outline')
下一步是将这些顶点添加到网格中并创建连接边缘。由于字符中的所有曲线段都是闭合的,我们必须注意添加一个额外的边缘来连接最后一个和第一个顶点,如高亮行所示。为了保险起见,我们删除可能存在于插值曲线段中的任何重复项。我们将顶点添加到TextTop顶点组中,并存储新边缘的列表以供将来参考。
loop=[]
for v in vlist:
offset=len(me.verts)
me.verts.extend(v)
edgeoffset=len(me.edges)
me.edges.extend([(i+offset,i+offset+1) for i in range(len(v)-1)])
me.edges.extend([(len(v)-1+offset,offset)])
me.remDoubles(0.001)
me.assignVertsToGroup('TextTop', range(offset,len(me.verts)),1.0, Blender.Mesh.AssignModes.ADD)
loop.append([me.edges[i] for i in range(edgeoffset,len(me.edges) )])
对于我们在上一部分存储的每个边缘环路,我们围绕它构建一个新的、略微更大的边缘环路,并将这些新顶点和边缘添加到我们的网格中。我们还想在这些边缘环路之间构建面,这从高亮行开始:在这里,我们使用 Python 内置函数zip()来配对两个边缘环路的边缘。每个边缘环路都通过一个utility函数(在Tools.py中可用)进行排序,该函数将边缘排序为它们相互连接的顺序。对于每一对边缘,我们构建两种可能的顶点索引排列,并计算哪一种形成一个未扭曲的面。这种计算是通过least_warped()函数(代码未显示)完成的,该函数基本上比较由两种不同的顶点排序定义的面的周长。未扭曲的面将具有最短的周长,然后将其添加到网格中。
for l in range(len(loop)):
points = expand.expand(me,loop[l],0.02,loop[:l]+loop[l+1:])
offset=len(me.verts)
me.verts.extend(points)
edgeoffset=len(me.edges)
me.edges.extend([(i+offset,i+offset+1) for i in range(len(points)-1)])
me.edges.extend([(len(points)-1+offset,offset)])
eloop=[me.edges[i] for i in range(edgeoffset,len(me.edges))]
me.assignVertsToGroup('Outline',range(offset,len(me.verts)),1.0,Blender.Mesh.AssignModes.ADD)
faces=[]
for e1,e2 in zip( expand.ordered_edgeloop(loop[l]),expand.ordered_edgeloop(eloop)):
f1=(e1.v1.index,e1.v2.index,e2.v2.index,e2.v1.index)
f2=(e1.v2.index,e1.v1.index,e2.v2.index,e2.v1.index)
faces.append(least_warped(me,f1,f2))
me.faces.extend(faces)
我们省略了挤出字符边缘环路的代码,但以下行提供了信息,展示了如何填充边缘环路。首先,我们通过使用两个实用函数(这些是字符的挤出边缘)选择所有相关边缘。接下来,我们调用fill()方法。只要这些封闭的边缘环路位于同一平面内,此方法就会填充任何集合。它甚至还会处理孔洞(如字母e中的小岛):
deselect_all_edges(me)
select_edges(me,'TextBottom')
me.fill()
添加边框只是在我们的人物周围添加一个矩形边缘环路的问题。如果此边缘环路与Outline顶点组中的顶点一起选择,则可以再次使用fill()方法填充此边框。这里没有展示。我们以一些收尾工作结束:我们尽可能使用triangleToQuad()方法将网格中的三角形转换为四边形,然后细分网格。我们还添加了一个子表面修改器,将所有面的smooth属性设置为平滑,并重新计算所有面的法线,使其一致地指向外部。
me.triangleToQuad()
me.subdivide()
mod = ob.modifiers.append(Blender.Modifier.Types.SUBSURF)
mod[Blender.Modifier.Settings.LEVELS]=2
select_all_faces(me)
set_smooth(me)
select_all_edges(me)
me.recalcNormals()
注意
隐藏的钩子修改器:
我们已经看到,Blender 中可用的修改器可以通过 Python 添加到对象中。然而,有一个修改器可以添加,但在 Blender 的 GUI 中似乎没有等效的修改器。这就是所谓的钩子修改器。在 Blender 中,钩子是一种将顶点连接到对象的方式(因此它是顶点父化的逆过程,其中我们将对象连接到顶点)。在应用程序本身中,可以通过编辑模式下的网格 | 顶点 | 添加钩子菜单访问它。一旦添加,它将出现在修改器列表中。从程序员的视角来看,钩子修改器与其他修改器没有区别,但遗憾的是,其类型和 API 中需要的设置都没有被记录。
飞溅的火花
通过向对象添加合适的粒子系统,可以轻松地创建火花和各种发光效果。许多粒子系统的参数可以通过顶点组的顶点权重来控制,包括发射粒子的局部密度。
在这个例子中,我们想要模拟被称为“圣埃尔摩之火”的电气现象。这是在某些情况下,尤其是在雷暴来临之际,一些物体开始发光的现象。这种发光被称为电晕放电(en.wikipedia.org/wiki/St._Elmo%27s_fire),并且在大型结构的尖锐和突出部分最为明显,例如无线电天线或避雷针,那里的电场是造成这种现象的最强。
为了以令人信服的方式影响从网格发射出的粒子数量,我们需要计算一个名为局部曲率的量,并将这个曲率以适当缩放的权重形式存储在顶点组中。然后,我们可以将这个顶点组应用到粒子上下文中的额外面板的密度参数上,以控制发射。
一个网格可以具有任何形状,大多数情况下没有整洁的公式可以近似其形状。因此,我们以必要而粗略的方式近似局部曲率(更多细节和一些复杂的数学,请参阅en.wikipedia.org/wiki/Mean_curvature),通过计算网格中每个顶点连接边的所有边曲率的平均值。在这里,我们定义边曲率为归一化顶点法向量和边向量的点积(即从顶点到其邻居的向量形式)。如果边相对于法线向下弯曲,则该乘积将为负,如果边向上弯曲,则乘积为正。我们将反转这个符号,因为我们更习惯于将正曲率与尖峰而不是与凹槽联系起来。另一种看待这个问题的方式是,在正曲率区域,顶点法向量和从同一顶点开始的边之间的角度大于 90°。
下图说明了这个概念——它描绘了一系列通过边连接的顶点。每个顶点都有一个关联的顶点法线显示(箭头)。标记为 a 的顶点具有正曲率,标记为 b 的顶点具有负曲率。有两个标记为 c 的顶点位于零曲率区域——也就是说,在这些位置,表面是平的,顶点法线垂直于边。

计算局部曲率
计算网格中每个顶点的局部曲率并返回一个包含归一化权重的列表的函数可以如下实现:
from collections import defaultdict
def localcurvature(me,positive=False):
end=defaultdict(list)
for e in me.edges:
end[e.v1.index].append(e.v2)
end[e.v2.index].append(e.v1)
weights=[]
for v1 in me.verts:
dvdn = []
for v2 in end[v1.index]:
dv = v1.co-v2.co
dvdn.append(dv.dot(v1.no.normalize()))
weights.append((v1.index,sum(dvdn)/max(len(dvdn),1.0)))
if positive:
weights = [(v,max(0.0,w)) for v,w in weights]
minimum = min(w for v,w in weights)
maximum = max(w for v,w in weights)
span = maximum - minimum
if span > 1e-9:
return [(v,(w-minimum)/span) for v,w in weights]
return weights
函数 localcurvature() 接收一个网格和一个可选参数,并返回一个包含顶点索引及其权重的元组列表。如果可选参数为 true,则丢弃计算出的任何负权重。
繁重的工作在突出显示的行中完成。在这里,我们遍历所有顶点,然后在内部循环中检查每个连接的边,从预先计算的字典中检索另一端的顶点。然后我们计算 dv 作为边向量,并将此边向量与归一化顶点法线的点积追加到列表 dvdn 中。
weights.append((v1.index,sum(dvdn)/max(len(dvdn),1.0)))
前一行可能看起来很奇怪,但它会附加一个由顶点索引及其平均曲率组成的元组,其中平均曲率是通过将曲率列表求和然后除以列表中的值数来计算的。因为列表可能为空(这发生在网格包含未连接的顶点时),我们通过将其除以列表长度或 1(取较大者)来防止除以零错误。这样,我们通过避免 if 语句使代码更易于阅读。
代码概要:curvature.py
在我们有了 localcurvature() 函数之后,实际的曲率脚本变得相当简洁(完整脚本作为 curvature.py 提供):
if __name__ == "__main__":
try:
choice = Blender.Draw.PupMenu("Normalization%t|Onlypositive|Full range")
if choice>0:
ob = Blender.Scene.GetCurrent().objects.active
me = ob.getData(mesh=True)
try:
me.removeVertGroup('Curvature')
except AttributeError:
pass
me.addVertGroup('Curvature')
for v,w in localcurvature(me,positive=(choice==1)):
me.assignVertsToGroup('Curvature',[v],w,Blender.Mesh.AssignModes.ADD)
Blender.Window.Redraw()
except Exception as e:
Blender.Draw.PupMenu('Error%t|'+str(e)[:80])
突出显示的行显示了如何通过尝试捕获当组不存在时引发的 AtrributeError 来从 Mesh 对象中移除可能存在的 Curvature 顶点组。接下来,我们再次以相同的名称添加该组,使其完全为空。最后突出显示的行显示了如何单独添加每个顶点,因为任何顶点可能具有不同的权重。
所有操作都围绕着一个 try … except 结构,该结构会捕获任何错误,并在发生任何异常情况时弹出一个友好的信息消息。最可能的情况是,这发生在用户忘记选择一个 Mesh 对象的情况下。

整合一切:圣埃尔摩之火
通过手动建模一个简单的杆对象并使用 curvature.py 计算曲率,制作了尖锐尖端杆的放电示意图。

然后,添加了一个粒子系统,并在额外标签页中将密度参数设置为Curvature顶点组。杆和粒子系统分别赋予了不同的材质:简单的灰色和白色光环。粒子模拟了 250 帧,并渲染了第 250 帧以供插图。

骨骼
骨骼可能被认为是动画的骨干,因为以可控的方式变形网格,这可以在给定的帧上键入,对于动画师来说,这是在良好的控制下摆姿势的必要条件。
Blender 的骨骼实现为绑定器和动画师提供了大量的可能性,但最终,骨骼首先是一系列连接的骨骼,其中每个骨骼将变形网格的一部分。这些骨骼相对于彼此的运动可以通过几种不同的约束来控制。
虽然骨骼可以配置为通过包络来施加影响,从而基本上在一定的半径内变形目标网格中的任何顶点,但它们也可以配置为仅变形与骨骼名称相同的顶点组中的顶点。这种变形进一步由顶点组中顶点的权重控制,使我们能够精细调整骨骼影响。
滴答声
为了说明骨骼的基本可能性,我们将绑定一个简单的时钟模型。这个时钟由一个由三个独立的、非连接的子网格组成的单一网格组成——主体、小指针和大指针。每个时钟指针的顶点属于两个独立的顶点组——一个用于连接到时钟中心的指针部分,另一个用于指针本身的末端。这种设置允许进行卡通风格的动画,例如我们可以使指针的尖端跟随实际的运动。
代码概要:clock.py
我们将采取以下步骤来以我们期望的方式绑定我们的时钟:
-
导入网格数据。
-
创建时钟网格。
-
创建顶点组。
-
创建骨骼对象。
-
在骨骼内创建骨骼。
-
关联骨骼修改器。
从概要到代码的转换几乎是逐字逐句的,并且有些重复,因为许多指令都要为每个骨骼重复(完整代码可在clock.py中找到):
me=Blender.Mesh.New('Clock')
me.verts.extend(clockmesh.Clock_verts)
me.faces.extend(clockmesh.Clock_faces)
scn=Blender.Scene.GetCurrent()
ob=scn.objects.new(me)
scn.objects.active=ob
me.addVertGroup('BigHand')
me.assignVertsToGroup('BigHand',clockmesh.Clock_vertexgroup_BigHand,1.0, Blender.Mesh.AssignModes.ADD)
… <similar code for LittleHand, BigArm and LittleArm vertex groupsomitted> ...
ar = Blender.Armature.New('ClockBones')
ar.envelopes=False
ar.vertexGroups=False
obbones = scn.objects.new(ar)
mod = ob.modifiers.append(Blender.Modifier.Types.ARMATURE)
mod[Blender.Modifier.Settings.OBJECT]=obbones
mod[Blender.Modifier.Settings.ENVELOPES]=False
mod[Blender.Modifier.Settings.VGROUPS]=True
ar.makeEditable()
bigarm = Blender.Armature.Editbone()
bigarm.head = vec(0.0,0.0 ,0.57)
bigarm.tail = vec(0.0,0.75,0.57)
ar.bones['BigArm'] = bigarm
bighand = Blender.Armature.Editbone()
bighand.head = bigarm.tail
bighand.tail = vec(0.0,1.50,0.57)
bighand.parent = bigarm
ar.bones['BigHand'] = bighand
… <similar code for the little hand omitted> …
ar.update()
obbones.makeParent([ob])
突出显示的是重要事项。首先,我们在骨骼对象上禁用了包络和vertexGroups属性。这看起来可能有些奇怪,但这些属性是过去骨骼不是应用于网格的修改器,而是通过作为Mesh对象(至少就我所知,可用的文档对此有些含糊)的父对象来施加影响时的残留。我们通过在骨骼修改器上设置属性来确定要使用哪种影响。
在将一个臂架修改器与我们的Mesh对象关联后,我们通过逐个构建臂架骨骼。在我们向臂架添加任何骨骼之前,我们必须调用其makeEditable()方法。请注意,这种针对臂架的编辑 模式 用于 臂架是与其他对象不同的,这些对象可以通过Blender.Window.editMode()函数设置编辑模式!一旦我们完成,我们再次通过调用update()方法回到正常模式。
您可能已经注意到,在构建我们的臂架时,我们创建了Editbone对象的实例。在编辑模式之外,这些相同的骨骼被称为Bone对象。两者都指向同一个骨骼,但提供了适用于编辑模式或对象模式的不同功能和属性。为了适应类似的方法,Blender 还提供了PoseBone对象来在姿势模式中操纵骨骼。
通过指定骨骼的头和尾位置(当将骨骼表示为八边形时,分别是钝端和锐端)来在臂架中定位骨骼。为了连接骨骼,仅将一个骨骼的尾位置设置为另一个骨骼的头位置是不够的。为了使一个骨骼跟随另一个骨骼的运动,它必须被设置为它的父对象。通过将子对象的parent属性设置为指向父骨骼对象来实现父化。在我们的示例中,我们将每个手骨与其对应的臂骨进行了父化。
臂架内的骨骼通过其名称进行索引。如果臂架修改器的VGROUPS属性被设置,骨骼的名称应该与其影响的顶点组的名称相同。
我们示例代码的最后一行同样很重要;必须将Mesh对象设置为臂架的父对象。在臂架和网格将保持在同一位置且只有臂架中的单个骨骼会移动的情况下,这可能会显得有些多余;但如果不这样做,在交互式更改姿势时将导致网格显示异常(例如,您必须将网格更改为编辑模式然后再返回,例如,以查看姿势对臂架的影响,这是完全不实用的)。我们的绑定结果将看起来像这样(我们将臂架的显示模式设置为 X 射线,以便通过网格可见):

渲染结果可能看起来像这样:

我们可能想要限制单个骨骼的运动仅限于围绕 z 轴的旋转,这可以通过添加约束来实现。我们将在下一章中遇到约束。
好吧,有点像 backbone boy!
我们到目前为止关于绑定的知识也可以应用到creepycrawlies.py中。如果我们想扩展生成的模型的功能,我们可以将一个臂架修改器与生成的网格关联起来。我们必须创建一个带有适当骨骼集的臂架对象。
由于我们已经在mymesh模块中将顶点按身体部位分组,因此将它们与顶点组和一个匹配的骨骼关联是微不足道的。但创建骨骼本身可能很多,并且应该以正确的方式定位和连接,这并不简单。
让我们看看一些基本元素可能如何实现(完整的代码在creepycrawlies.py中)。首先,我们必须创建一个骨架并使其可编辑,以便添加骨骼:
ar = Blender.Armature.New('BugBones')
ar.autoIK = True
obbones = scn.objects.new(ar)
ar.makeEditable()
我们还可以设置任何改变骨架行为或显示方式的属性。在这里,我们只是启用autoIK功能,这将使动画师操纵我们生物体可能非常长的尾巴变得更加简单。
下一步是为每组顶点创建一个骨骼。以下代码中的vgroup列表包含元组(vg,vlist,parent,connected),其中vg是顶点组的名称,vlist是属于此组的顶点索引列表。我们创建的每个骨骼可能有一个父级,并且它可能物理上连接到父级。这些条件由元组的parent和connected成员表示:
for vg,vlist,parent,connected in vgroup:
bone = Blender.Armature.Editbone()
bb = bounding_box([verts[i] for i in vlist])
对于我们创建的每个骨骼,我们计算该骨骼将影响的顶点组中所有顶点的边界框。接下来要做的事情是定位骨骼。根据我们的设置,我们生物体的所有身体段都将沿着 y 轴延伸,除了翅膀和腿,它们沿着 x 轴延伸。我们首先检查这一点,并相应地设置axis变量:
axis=1
if vg.startswith('wing') or vg.startswith('leg'): axis = 0
骨架内的骨骼按名称索引,骨骼末端的坐标分别存储在其head和tail属性中。因此,如果我们有一个父级骨骼,并且我们想确定其平均 y 坐标,我们可以按以下方式计算:
if parent != None :
parenty = (ar.bones[parent].head[1] +ar.bones[parent].tail[1])/2.0
我们计算这个位置是因为像腿和翅膀这样的部分有父级骨骼(即,它们随着父级骨骼移动)但不是从头到尾连接。我们将从父级骨骼的中心开始定位这些骨骼,为此我们需要父级的 y 坐标。沿着 y 轴排列的骨骼段沿着 y 轴定位,因此它们的 x 和 z 坐标为零。腿和翅膀段段的 x 和 z 坐标来自它们的边界框。如果骨骼是连接的,我们只需将其头部位置设置为父级尾巴位置的副本(如下所示)。
注意
Blender 的Vector类提供了copy()函数,但奇怪的是没有提供__copy__()函数,所以它不会与 Python 的copy模块中的函数很好地配合。
if connected:
bone.head = ar.bones[parent].tail.copy()
else:
if axis==1:
bone.head=Blender.Mathutils.Vector(0,bb[1][0],0)
else:
bone.head=Blender.Mathutils.Vector(bb[0][1],parenty,bb[2][1])
The tail position of the bone is calculated in a similar manner:if axis==1:
bone.tail=Blender.Mathutils.Vector(0,bb[1][1],0)
else:
bone.tail=Blender.Mathutils.Vector(bb[0][0],parenty,bb[2][0])
创建骨骼的最终步骤是将它添加到骨架中,并设置骨骼特定的选项以及任何父级关系。
ar.bones[vg] = bone
if parent != None :
bone.parent=ar.bones[parent]
else:
bone.clearParent()
if connected: bone.options=Blender.Armature.CONNECTED
注意,在前面的代码中,操作顺序很重要:parent属性只能在添加到骨架的骨骼上清除,CONNECTED选项只能在具有父级的骨骼上设置。
再次提醒,我们应该注意 Blender 的一些特性。可以通过将其parent属性分配给骨骼来设置骨骼的父对象。如果没有父对象,此属性将返回None。然而,我们不能将None分配给此属性,我们必须使用clearParent()函数来移除父关系。

材料
材料是赋予对象外观的东西。在 Blender 中,材料非常灵活,因此相对复杂。几乎可以控制从对象反射的光的任何方面,不仅可以通过简单的参数,还可以通过图像映射和节点网络来实现。
最多可以与一个对象关联 16 种材料,并且在一个对象内部,各个部分可以引用这 16 种材料中的任意一种。在Text3d对象中,每个单独的字符可能引用不同的材料,而在曲线中,这一点适用于每个控制点。
从开发者的角度来看,将材料分配给对象是一个两步的过程。首先,我们必须定义一种新材料,然后我们必须将材料或材料分配给一个对象。如果我们能引用已经存在的材料,第一步可以省略。
如果一个对象如网格已经定义了面,那么我们仍然需要为每个面分配一种材料。如果定义了活动材料,新创建的面将分配活动材料。
一个小的代码片段说明了我们如何将材料分配给Mesh对象。在这里,我们将具有白色漫反射颜色的材料分配给所有偶数编号的面,并将具有黑色漫反射颜色的材料分配给所有奇数编号的面,在一个称为ob的Mesh对象中。
me=ob.getData(mesh=1)
mats=[ Blender.Material.New(), Blender.Material.New()]
mats[0].rgbCol=[1.0,1.0,1.0]
mats[1].rgbCol=[0.0,0.0,0.0]
ob.setMaterials(mats)
ob.colbits=3
for f in me.faces:
if f.index%2 == 0 :
f.mat=0
else:
f.mat=1
突出的行确保每个面使用的材料索引指向分配给对象的材料。(我们将在下一节中看到,也可以将材料与网格数据关联。)
对象材料与 ObData 材料
在 Blender 中,一个Mesh对象和包含Mesh对象的最顶层 Blender 对象都可以有自己的 16 种材料列表。如果我们想要实例化许多具有不同材料的应用的相同网格副本,这很方便。然而,在某些情况下,我们可能希望将一些或所有材料应用于Mesh而不是对象。这由对象的colbits属性控制。此属性由 16 位组成,每一位指示是否使用对象或Mesh中的材料。我们已经在上一节中看到了一个例子。
Curve对象也可能有自己的材料集,选择实际材料遵循与Mesh对象相同的规则。元球也有自己的材料集,在材料集之间切换的方式也是相同的,但与由部分组成的许多类型对象不同(参见下一节),无法将不同的材料与元球内的不同元素关联起来(这在 GUI 中也是如此:编辑上下文中的链接和材料中的按钮用于将材料索引分配给单个元球元素,但它们没有效果)。只使用材料列表中的第一个槽位。
注意,不自行渲染的对象,如骨架和格子,没有关联的材料(也就是说,与包含骨架或格子的顶级对象关联的任何材料都被忽略)。一些没有关联材料的对象可能具有与之关联的纹理。例如,World和Lamp对象可能具有关联的纹理来控制它们的颜色。
将材料分配给对象的各个部分
在一个网格中,每个面可能有自己的相关材料。这种材料通过其在材料列表中的索引来识别,并存储在mat属性中。在一个Text3d对象中,每个字符可能有自己的材料,同样通过其在材料列表中的索引来识别。这次,这个索引不是直接存储在属性中,而是可以通过接受字符在文本中索引的accessor方法来设置或检索。
Curve(CurNurb对象)内的部分可以通过其setMatIndex()方法分配材料索引。可以通过相应的getMatIndex()方法从中检索索引。请注意,将材料与由单行组成且未设置挤出宽度或与斜面对象关联的曲线关联起来将没有可见效果,因为这些曲线不会被渲染。
下面的代码片段显示了如何将不同的材料分配给Text3d对象内的不同字符。代码本身很简单,但如您所注意到的,我们定义了一个包含三个材料的列表,但只使用了一个。这是浪费的,但这是为了解决setMaterial()的一个特性。其材料索引参数应该偏移一个,例如,索引 2 指的是列表中的第二个材料,然而,可能通过的最大索引没有偏移一个。所以如果我们想使用两种材料,我们必须使用索引 1 和 2 来访问材料 0 和 1,但实际的材料列表应该包含三个材料,否则我们无法将 2 作为setMaterial()的参数传递。
mats=[Material.New(),Material.New(),Material.New()]
mats[0].rgbCol=[1.0,1.0,1.0]
mats[1].rgbCol=[0.0,0.0,0.0]
mats[2].rgbCol=[1.0,0.0,0.0]
ob.setMaterials(mats)
ob.colbits=3
txt=ob.getData()
for i in range(len(txt.getText())):
txt.setMaterial(i,1+i%2)
突出的代码显示了通过1进行的修正。完整的代码以TextColors.py的形式提供。
顶点颜色与面材料
我们尚未处理的一个处理材料的重要方面是顶点 颜色。在网格中,每个顶点可能有自己的顶点颜色。顶点颜色与材料不同,但顶点颜色是否具有可见效果由材料的模式标志控制。为了使材料使用任何顶点颜色,其VColPaint位应该通过调用其setMode()方法来设置。当以这种方式使用时,顶点颜色确定材料的漫反射颜色,而所有材料的常规属性控制这种漫反射颜色的渲染方式。顶点颜色的一个常见用途是烘焙计算成本高昂的效果,如环境遮蔽。由于顶点颜色可以非常快速地渲染,因此即使在实时设置中,如游戏引擎中,也可以通过这种方式近似环境遮蔽。(近似是因为它不会以相同的方式对光照变化做出反应。)
面的col属性中存储了作为Mesh、MCol对象(基本上是 RGBA 元组)的顶点颜色。col属性是一个列表,包含对面的每个顶点上的MCol对象的引用。当你考虑到材料是与面相关而不是与顶点相关这一事实时,这种安排是有意义的。当顶点的顶点颜色不同时,它们将在面上进行线性插值。
只有当网格的vertexColors属性设置为True时,才能将值分配给面的col属性。
以下示例代码片段显示了如何设置网格的顶点颜色。我们根据顶点的 z 坐标选择灰度阴影(突出显示)。
import Blender
ob=Blender.Scene.getCurrent().objects.active
me=ob.getData(mesh=1)
me.vertexColors=True
for f in me.faces:
for i,v in enumerate(f.verts):
g = int(max(0.0,min(1.0,v.co.z))*255)
f.col[i].r=g
f.col[i].g=g
f.col[i].b=g
mats=[Blender.Material.New()]
mats[0].setMode(Blender.Material.Modes['VCOL_PAINT'])
ob.setMaterials(mats)
ob.colbits=1
ob.makeDisplayList()
Blender.Window.RedrawAll()
完整的代码作为VertexColors.py提供。
为我们的雕刻添加材料
为了使我们的雕刻活动更加完美,我们将添加两种材料。一种材料索引分配给表面的顶点,另一种分配给雕刻的凹槽中的顶点。这样,例如,我们可以在一块风化的石头上创建新刻字母的外观。
由于我们之前定义了一些方便的顶点组,因此分配材料索引只需遍历所有面,并根据顶点所属的顶点组将适当的材料索引分配给面的每个顶点。下面显示的函数采用了一种稍微更通用的方法,它接受一个网格和一个正则表达式列表,并根据它是否属于具有与正则表达式匹配的名称的顶点组,将材料索引分配给每个面。
这个功能使得将相同的材料索引分配给所有具有相似名称的顶点组变得非常容易,例如由creepycrawlies.py创建的网格的所有尾段和胸段(这些名称都像tail.0、tail.1……等等)。
该功能在 Tools.py 中可用。它依赖于 Python 的 re.search() 函数,该函数将正则表达式与字符串进行匹配。高亮行显示我们嵌入所谓的锚(^ 和 $)。这样,正则表达式 aaaa 将仅匹配名称为 aaaa 的顶点组,而不是名称为 aaaa.0 的顶点组,这样我们就可以在需要时区分它们。如果我们想匹配所有以 tail 开头的顶点组名称,我们可以传递正则表达式 tail.*,例如。
注意
正则表达式是匹配字符串的极其强大的方式。如果您不熟悉它们,您应该查阅 Python 模块的文档(docs.python.org/library/re.html)。从更温和的教程开始,例如,docs.python.org/howto/regex.html。
在此函数中需要注意的另一件事是我们使用集合操作的方式。这些操作大大加快了速度,因为 Python 的集合操作非常快。我们在这里使用它们来高效地检查构成面的顶点集(或更确切地说,它们的索引)是否都在某个顶点组的顶点索引集中。我们预先计算了属于顶点组的顶点索引集和每个面的顶点索引集,并将它们存储在字典中以方便访问。这样,我们只为每个顶点组和每个面分别创建这些集一次,而不是每次匹配正则表达式时都重新创建每个集。对于大型网格,这可以节省大量时间(以存储为代价)。
import re
def matindex2vertgroups(me,matgroups):
if len(matgroups)>16 :
raise ArgumentError("number of groups larger than number ofmaterials possible (16)")
groupnames = me.getVertGroupNames()
vertexgroupset={}
for name in groupnames:
vertexgroupset[name]=set(me.getVertsFromGroup(name))
print name,len(vertexgroupset[name])
faceset={}
for f in me.faces:
faceset[f.index]=set([v.index for v in f.verts])
for i,matgroup in enumerate(matgroups):
for name in groupnames:
if re.search('^'+matgroup+'$',name):
for f,vset in faceset.items():
if vset.issubset(vertexgroupset[name]) :
me.faces[f].mat = i
break

摘要
在本章中,我们看到了如何通过在网格中定义顶点组来简化最终用户的操作,从而便于轻松选择某些特性。我们还看到了如何将材质分配给顶点,以及如果需要如何创建新的材质。我们迈出了为网格设置骨架的第一步。具体来说,我们学习了:
-
如何定义顶点组
-
如何将顶点分配到顶点组
-
如何将材质分配给面
-
如何将顶点颜色分配给顶点
-
如何设置边属性
-
如何添加修改器
-
如何绑定骨骼
接下来,我们将超越静态对象,看看如何控制对象的移动。
第四章:Pydrivers 和约束
当设计具有可动部件的复杂对象时,我们希望控制这些部件之间的运动关系。有时,我们可能想通过使用物理引擎(如 bullet)来尝试模拟现实,但通常这要么不够准确,要么不能为动画师提供足够的控制。大多数时候,巧妙地使用多个约束可以达到我们的目标,但有时限制和关系不能简单地用约束和关键帧运动来表示。在这些情况下,我们可以通过使用 Python 定义自己的约束或动画属性之间的关系来扩展 Blender 的功能。
在本章中,我们将看到如何将内置约束与 Blender 对象关联起来,以及如何使用所谓的pydrivers定义动画属性之间的复杂关系。我们还将定义新的复杂约束,这些约束可以像内置约束一样使用。我们不会在这里探讨为动画属性定义关键帧,因为那些将在后面的章节中遇到。
在本章中,我们将看到:
-
如何通过 Python 表达式从一个 IPO 驱动另一个 IPO
-
如何绕过pydrivers固有的某些限制
-
如何通过添加约束来限制对象和骨骼的运动
-
如何编写一个 Python 约束,将对象吸附到另一个对象最近的顶点上
这里有很多内容需要介绍,所以让我们首先从一些定义开始,以便清楚地了解我们正在处理的内容。
掌握动画属性
Blender 的系统功能多样,但也很复杂。在我们能够通过 Python 脚本操作动画属性之前,有必要彻底理解其中涉及的概念。
IPO
在 Blender 中,几乎任何属性都可以进行动画处理。通常,这是通过固定某些属性(如物体在特定关键帧的位置)的值,并在中间帧之间对这些值进行插值来完成的。Blender 将相关的动画属性组合在一起,称为 IPO。例如,所有空间属性(如位置、旋转和缩放)都被组合为一个对象类型 IPO,可以与几乎任何 Blender 对象相关联,例如Mesh、Camera或Lamp。Blender 材质的许多属性被组合在材质类型 IPO 中。材质类型 IPO 可以与任何具有相关材质的对象相关联。同样,灯类型 IPO 应该与Lamp对象相关联。下一个表格给出了可能的 IPO 类型概述。
注意
IPO听起来像是一个缩写,可能确实如此,但它确切代表什么有点模糊。Blender 维基百科表示它代表插值系统,但有时也会遇到插值对象。然而,IPO 通常作为名词单独使用,所以这次讨论有点学术性。
每个 IPO 可以与多个对象相关联。例如,可以通过将单个适当的对象类型 IPO 与它们关联来以相同的方式动画许多对象的旋转。在 Blender Python API 中,IPO 由 IPO 对象表示。IPO 对象可以通过setIpo()方法与另一个对象相关联。以下表格概述了 IPO 类型、典型通道以及它们可以应用于的对象类型。有关 Blender.IPO 模块的详细信息,请参阅 API 文档(www.blender.org/documentation/249PythonDoc/index.html)。
| IPO 类型 | IPO 通道(一些示例,有关完整列表,请参阅 API 文档) | 与这些 Blender 对象相关 |
|---|---|---|
| 对象 | LocX、LocY、LocZ(位置)RotX、RotY、RotZ(旋转)ScaleX、ScaleY、ScaleZ(缩放) |
所有可以定位的 Blender 对象,例如Mesh、Lamp、Camera等 |
| 姿势 | RotX、RotY、RotZ(旋转) |
骨骼 |
| 材质 | R、G、B(漫反射颜色) |
任何接受材质的对象 |
| 纹理 | Contrast |
任何具有关联纹理的对象,例如Mesh、Lamp、World等 |
| 曲线 | Speed |
曲线 |
| 灯光 | Energ(能量)R、G、B(颜色) |
灯光 |
| 世界 | HorR、HorG、HorB(地平线颜色) |
世界 |
| 约束 | Inf(影响) |
约束 |
| 序列 | Fac(因子,例如,音频条目的音量) |
序列 |
IPO 通道和 IPO 曲线
给定类型的 IPO 将包含一系列相关的动画属性。这些属性通常被称为通道。例如,在对象类型 IPO 中的通道有LocX(位置的 x 分量)和RotY(绕 y 轴的旋转)。每个通道都由一个实现所需功能以在关键帧之间返回插值值的IPOCurve对象表示。
材质类型 IPO 中的通道示例可以是SpecB——漫反射颜色的蓝色分量。
可以通过属性访问给定 IPO 的IPOCurve对象,例如,如果myipo是对象类型 IPO,则myipo.LocX将引用一个LocX IPOCurve。
为了说明这些概念,假设我们想要沿着 x 轴动画一个简单立方体的移动。我们希望在帧编号 1 开始运动,并在帧编号 25 结束。在 Blender 中,可以通过以下步骤实现:
-
通过选择添加 | 网格 | 立方体添加一个简单的
Cube,并确保你再次处于对象模式。 -
前往第一帧(例如,通过在 3D 视图窗口下方的帧编号小部件中输入帧编号)。
-
通过选择对象 | 插入关键帧 | 位置来插入位置关键帧。在
IPOCurve编辑窗口中,此位置关键帧将显示为对象类型 IPO(在以下屏幕截图中突出显示)。![IPO 通道和 IPO 曲线]()
当前帧以绿色垂直线可见。一个位置 IPO 包含三个不同的通道(沿 x 轴的位置LocX,以及沿 y 轴和 z 轴的位置LocY和LocZ)。这些通道以不同颜色的图表表示(它们可能重叠)。这些图表可以直接在IPOCurve编辑器中操作,但在这里我们将在 3D 视图窗口中添加第二个关键帧。
-
在 3D 视图窗口中,转到第 25 帧。
-
选择立方体并将其沿 x 轴向右移动。
-
通过选择对象 | 插入关键帧 | Loc插入第二个位置关键帧。现在我们可以看到代表三个位置 IPO 通道的每个图表上都有一个第二个点被定义。因为我们只改变了立方体的 x 轴位置,所以其他通道的图表保持平坦,但
LocX通道显示了 x 位置变化如何随着每一帧的进展而变化。![IPOchannels and IPOCurves]()
通过添加更多的关键帧,我们可以使任何运动变得尽可能复杂,但如果我们的对象需要遵循一个精确预计算的路径,这将会变得非常繁琐。在本章的后面部分,我们将看到如何通过 IPO 程序来操作代表这些通道的IPOCurve对象。
约束
约束在 Blender 中与顶级 Blender 对象或 Blender 骨骼对象相关联,并由Constraint对象表示。Blender 对象和骨骼有一个constraints属性,这是一个对象,它实现了一系列约束和方法,用于添加、删除和改变这个序列中约束的顺序(能够改变约束的顺序是必要的,因为在某些情况下,约束应用的顺序很重要)。
当约束与一个对象关联时,结果将是约束参数和计算参数的混合。这种混合中约束参数或非约束参数的比例由influence属性决定,这甚至可以动画化。
驱动器和约束之间的区别
驱动器和约束在影响参数变化方式方面相关联,但它们也非常不同:约束作用于对象,而驱动器确定 IPO 曲线(动画参数)如何改变其他 IPO 曲线。并且,约束只能影响对象的空间属性,如位置、缩放或旋转,任何 IPO 曲线都可以由另一个 IPO 曲线驱动。这意味着即使是材料参数,如颜色或Lamp参数(如能量),也可以由另一个 IPO 驱动。不过,有一个限制:驱动其他 IPO 曲线的 IPO 曲线目前必须是对象的特殊属性,因此你可以通过某个对象的旋转来驱动材料的颜色,但不能通过灯的能量来驱动该颜色。此外,约束只能影响空间属性的事实意味着例如无法限制材料的漫反射颜色。以下表格显示了某些约束及其相关属性。有关Blender.Constraint模块的更多详细信息,请参阅 API 文档。
| 约束类型 | 典型属性 |
|---|---|
TrackTo |
Target(目标对象)Track(跟踪轴) |
Floor |
Target(目标对象) |
StretchTo |
Target(目标对象) |
CopyLocation |
Copy(要复制的位置组件) |
注意,还可以动画化约束的影响,在这种情况下,对象将有一个相关的约束类型 IPO。
使用约束编程
Blender 有许多可以应用于对象的约束。其中一些与驱动器类似,因为它们不限制对象的运动,而是复制一些参数,如旋转或位置。从开发者的角度来看,每个 Blender 对象都有一个constraints属性,它是一个约束对象的序列。这个序列可以被追加,并且可以从序列中删除项目。还可以改变项目的顺序。
| 方法 | 操作 | 示例 |
|---|---|---|
append(类型) |
向对象追加新的约束并返回该约束 | ob.constraints.append(Constraint.Type.TRACKTO) |
remove(约束) |
从对象中删除约束 | ob.constraints.remove(ob.constraints[0]) |
moveUp(约束) moveDown(约束) |
改变约束在约束列表中的位置 | ob.constraints.moveDown(ob.constraints[0]) |
[] |
访问约束的属性 | Con = ob.constraints[0] Con[Constraint.Settings.TARGET] = other |
新的 Constraint 对象不是通过构造函数实例化的,而是通过调用 constraints 属性的 append() 方法并指定要添加的约束类型来创建。append() 方法将返回新的 Constraint 对象,然后可以修改其设置。
使用 IPO 编程
IPO 通道可以从脚本中更改,就像约束一样,但它们比约束更为多样,因为存在许多不同类型的 IPO 通道,其中一些,特别是纹理通道和形状键,需要特殊处理。它们有自己独立的章节(第六章:形状键、IPO 和姿态),但在下一节中将展示 Python 在 IPO 通道中的不同用法。
PyDrivers
有许多情况,我们希望通过引用另一个属性来更改某些属性,但无法通过驱动另一个 IPO 通道来捕捉这种关系。通常,这是因为关系不是简单的线性依赖,例如,由圆形运动驱动的活塞。另一种情况是,如果关系是非连续的,例如,当开关处于某个位置时打开的灯光。
在这种情况下,关系可能由 Python 表达式或所谓的pydriver定义。pydriver 将另一个对象的 IPO 通道作为输入,并产生输出,该输出将驱动当前对象的通道。因为这些 Python 表达式可以访问完整的 Blender API,所以这些关系可以变得非常复杂。
PyConstraints
在内置的 IPO 通道驱动可能性受限时,可以使用 pydrivers 来绕过这些限制,而PyConstraints则允许我们在内置约束不足的情况下克服困难。例如,无法将一个物体的位置限制在另一个有孔物体的表面上。内置约束提供了一种方法,将位置限制在另一个物体(即floor约束)的上方。但如果我们希望物体在存在孔的位置下落,我们就必须自己编写这样的约束。正如我们将看到的,PyConstraints 允许我们做到这一点。
在我们完成所有这些介绍性说明之后,我们可以在下一节中再次转向编程。
设置时间——一统天下
如果不能以方便的方式设置时间,时钟有什么用?我们不想为每个指针定位,而是希望转动一个旋钮,同时移动大指针和小指针,其中小指针的移动速度是大指针的十二分之一。
因此,我们必须定义一个knob对象(我们可能不会渲染它),并通过这个旋钮的旋转来驱动时钟骨骼的旋转。
要设置驱动通道,我们遵循以下步骤:
-
在 3D 视图中,选择
bighand对象。 -
在 IPO 窗口中,请确保您已选择了对象 IPO 类型。在右侧,将有一个通道列表。通过左键单击标签为RotZ的通道来选择它。它将被突出显示。
![设置时间——一统天下]()
-
选择曲线 | 变换属性。将出现一个弹出窗口。点击添加驱动器按钮。
![设置时间——一统天下]()
-
在变换属性弹出窗口仍然存在的情况下,选择曲线 | 插入 1:1 映射,然后点击默认的一对一映射弹出窗口(或按Enter键)。生成的图表将作为一条直线、浅蓝色线出现在 IPO 编辑器窗口中。
![设置时间——一统天下]()
-
在变换属性弹出窗口中,点击浅绿色的 Python 图标。Python 图标将变为深绿色,现在可以在相邻的文本字段中输入 pydriver 表达式。输入以下代码行:
ob('Knob').RotZ*(360/(2*m.pi))/10
哇!如果你现在围绕knob对象的 z 轴旋转,大指针也会相应地旋转。不过,pydriver 表达式确实需要一些解释。高亮部分是驱动器——为我们驱动通道提供输入的对象通道。ob('Knob')部分是 pydriver 表达式允许的Blender.Object.Get('Knob')的缩写,而RotZ属性为我们提供了关于 z 轴的旋转。然而,这种旋转是以弧度为单位的,而旋转通道的 pydriver 表达式的结果应该是以度为单位的,因此我们将其乘以 360 度并除以 2π。最后,我们将计算出的度数除以十,因为出于某种神秘的原因,Blender 实际上并不期望度数,而是度数除以 10!(请注意,这种“除以十”的操作仅适用于旋转通道,不适用于其他任何通道!)
注意
1:1 映射
你可能会想知道为什么我们首先需要插入一个 1:1 曲线。嗯,驱动通道与其驱动器之间的关系包含一个额外的层次,那就是将 pydriver 的输出转换为最终输出的曲线。当然,我们可以调整这条曲线,但通常我们会在 pydriver 中进行所有精细调整,然后只插入一个 1:1 曲线。这种工作方式非常常见,因此 Blender 提供了一个专门用于这种情况的菜单项,因为为每个驱动通道反复创建必要的曲线是非常繁琐的。
当然,我们可以通过直接驱动旋转通道的旋转通道或通过复制旋转约束来实现同样的效果。这将节省我们奇怪的转换问题,但本节的目的是展示基础知识。
小指针是一个使用 pydriver 确实是有效解决方案的例子。(尽管通过调整 IPO 曲线本身我们也可以改变驱动通道的速度,但这不如简单的表达式清晰,对于更复杂的关系几乎不可能实现)我们重复之前显示的操作列表,但现在针对小指针对象,并输入以下 pydriver 表达式:
ob('Knob').RotZ*(360/(2*m.pi))/10/12
因为小指针的运行速度是大指针的十二分之一,所以我们使用与大指针相同的 pydriver 表达式,但将其结果除以十二。现在当我们围绕knob对象的 z 轴旋转时,大指针会跟随,小指针也会以设定的速度跟随。除了手动旋转旋钮外,还可以动画化旋钮的旋转来动画化两个时钟指针。完整的结果作为clock-pydriver.blend提供,时钟的渲染图像,其中旋钮驱动指针的运动在左上角可见,如图所示:

快捷键
在 pydriver 表达式中,可以使用一些有用的快捷键来节省输入。在逐步示例中,我们已经使用了ob('<name>')快捷键,该快捷键通过名称引用 Blender 对象,同样,可以通过me('<name>')和ma('<name>')分别访问Mesh对象和材质。此外,Blender模块作为b可用,Blender.Noise模块作为n可用,Python 的math模块作为m可用。这允许使用三角函数等表达式,例如正弦。这些设施足以覆盖许多问题,但它们可能仍然不足以满足需求,例如如果我们想导入外部模块。然而,我们将在下一节中看到,有一种方法可以绕过这些困难。
克服限制:pydrivers.py
pydrivers 的输入字段限制为 125 个字符,尽管提供的快捷键可以访问 Python 的math模块和一些 Blender 模块,允许更短的表达式,但提供的空间通常不足。此外,由于 pydrivers 必须是 Python 表达式,因此很难调试它们(例如,无法插入print语句)或实现类似if/then的功能。后者可以通过基于以下事实的巧妙技巧在一定程度上克服:在 Python 中,True和False在数值表达式中分别转换为 1 和 0,因此以下语句:
if a>b:
c=14
else:
c=109
可以表示为:
c = (a>b)*14 + (a<=b)*109
然而,这感觉有些笨拙,并且会两次评估条件。幸运的是,通过使用名为pydrivers.py的文本块,可以解决空间问题和限制单个表达式的问题。如果存在这样的文本块,其内容可以通过名为p的模块访问。例如,如果我们定义一个名为clamp()的函数在pydrivers.py中,其看起来如下:
def clamp(a,low,high):
if a<low : a=low
if a>high: a=high
return a
我们可以在 pydriver 表达式中调用此函数为p.clamp(a,14,109)。
在接下来的示例中,我们将大量使用pydrivers.py,这不仅因为它允许使用更复杂的表达式,还因为 pydriver 字段的宽度甚至小于其允许内容的长度,这使得阅读起来非常困难,因为你必须滚动以访问表达式的所有部分。
内燃机——关联复杂变化
想象一下,我们想要展示一个四冲程内燃机是如何工作的。这种发动机有很多运动部件,其中许多部件以复杂的方式相互关联。
为了看到这些确切的关系,查看以下插图可能很有用。它列出了我们将用于引用各种部件的名称。(我既不是汽车工程师也不是机械师,所以这些部件名称可能不准确,但至少我们将谈论相同的事情。如需更多信息,您可能想阅读en.wikipedia.org/wiki/Four-stroke_cycle。)

在我们开始配置部件,使其旋转和位置由另一个部件驱动之前,提前思考一下是件好事:在现实生活中,气缸内的活塞是由燃烧燃料的膨胀推动的,而活塞通过连接飞轮和分配皮带(或在我们的情况下是一些齿轮,这里没有展示)将运动传递回驱动进气和排气阀运动的凸轮轴。显然,我们无法直接遵循这个概念,因为没有某种类型的燃料对象来驱动其他对象,所以反转关系链更有意义。在我们的设置中,飞轮将驱动传动轴和不同的齿轮,传动轴将驱动大多数其他对象,包括活塞及其连杆。我们还将通过传动轴的旋转来驱动位于火花塞尖端的灯泡的能量。
传动轴将简单地跟随飞轮的旋转,下齿轮也是如此(这也可以通过copy rotation约束来实现,但在这里我们选择通过 pydrivers 来实现所有操作)。对应于RotX通道的 pydrivers 将如下所示:
ob('Flywheel').RotX/(2*m.pi)*36
对于仅仅复制旋转的东西来说,这看起来可能有些笨拙,但请记住,旋转是以弧度存储的,而 pydriver 表达式应该返回以 10 度为单位除以的旋转度数。
顶齿轮和两个凸轮轴也将跟随飞轮的旋转,但速度减半,并且旋转方向相反:
m.degrees(ob('Flywheel').RotX*-0.5)/10.0
为了说明如何访问 Python 的math模块中的函数,我们没有自己进行度数的转换,而是使用了math模块提供的degrees()函数。
我们将凸轮轴建模为凸轮正好向下。如果我们想通过传动轴的旋转来驱动进气凸轮轴的 x 轴旋转,我们必须考虑到它以一半的速度移动。此外,它的旋转稍微滞后,以匹配气缸的点火周期,因为它在第一次下冲时打开进气阀,并在点火火花前关闭阀门:
ob('DriveShaftPart').RotX/(2*m.pi)*18+9
出口凸轮轴的表达式几乎完全相同,只是它落后(此处为24)的量,但调整此发动机的工作留给真正的机械师:
ob('DriveShaftPart').RotX/(2*m.pi)*18+24

活塞的运动仅限于垂直方向,但确切的运动计算要复杂一些。我们感兴趣的是数量Q的位置——参见前面的图示——以及驱动轴中心与连杆连接点(图中L)之间的距离。由于连杆的长度是固定的,Q将作为驱动轴旋转角度α的函数而变化。从驱动轴中心到连杆连接点的距离也是固定的。我们称这个距离为 R。现在我们有一个边长为Q、L和R的三角形,以及一个已知的角α。由于这三个量(L、R 和α)是已知的,我们可以通过余弦定理(en.wikipedia.org/wiki/Law_of_cosines)计算出第四个量Q。因此,我们在pydrivers.py中定义了一个函数q(),当给定L、R和α时,它将返回长度Q:
def q(l,r,a): return r*cos(a)+sqrt(l**2-(r*sin(a))**2)
活塞的LocZ通道的表达式将简单地调用此函数,并使用适当的参数值:
p.q(1.542,0.655,ob('DriveShaftPart').RotX)
L和R的确切值是通过在Transform Properties窗口中记录连杆和驱动轴适当顶点的位置从网格中获得的。(3D 视图中的N键)
连杆本身可能使用与活塞和连杆的网格原点精确对齐相同的LocZ通道表达式。
然而,连杆的运动不仅限于 z 位置,因为它将围绕连接到活塞的点的 x 轴旋转。这种旋转的角度(图中γ)可以从数量L、R和α中推导出来:
def topa(l,r,a):
Q=q(l,r,a)
ac=acos((Q**2+l**2-r**2)/(2*Q*l))
if a%(2*pi)>pi : ac = -ac
return -ac
RotX的 pydriver 表达式将看起来像这样:
m.degrees(p.topa(1.542,0.655,ob('DriveShaftPart').RotX))/10.0
进气和排气阀门由各自凸轮轴的旋转驱动。实际凸轮的轮廓相当复杂,因此在这里,我们不是使用该轮廓的实际形状,而是通过某种看起来足够好的东西来近似它(即,在正确的时间以流畅而迅速的动作打开阀门)。以下图表显示了阀门行程作为旋转角度的函数:

为了达到这个目的,在pydrivers.py中我们定义了一个函数spike(),它将凸轮轴的旋转作为其参数,并返回一个在零角度周围急剧上升的值在0.0和1.0之间:
def spike(angle):
t = (cos(angle)+1.0)/2.0
return t**4
现在阀门的运动是线性的,但它所遵循的线倾斜了 10 度(进水阀向前,出水阀向后),因此我们需要驱动两个通道,LocZ 和 LocY,每个通道都乘以正确的数值以产生倾斜运动。因此,我们在 pydrivers.py 中定义了两个函数:
def valveZ(angle,tilt,travel,offset):
return cos(radians(tilt))*spike(angle)*travel+offset
def valveY(angle,tilt,travel,offset):
return sin(radians(tilt))*spike(angle)*travel+offset
这两个函数都会根据驱动对象的旋转角度返回一个距离。tilt 是阀门倾斜的角度(以度为单位),travel 是阀门沿着倾斜线可以旅行的最大距离,offset 是一个允许我们调整阀门位置的值。因此,进水阀的 LocZ 和 LocY 通道的对应 pydriver 表达式将变为:
p.valveZ(ob('CamInlet').RotX+m.pi,-10.0,-0.1,6.55)
和
p.valveY(ob('CamInlet').RotX+m.pi,-10.0,-0.1,-0.03)
(出水阀的表达式看起来相同,但倾斜角度为正值。)
到目前为止,所有通道都是对象通道,即位置和旋转。但也可以驱动其他通道,这正是我们需要驱动位于火花塞尖端灯的能量。在 pydrivers.py 中,我们首先定义了一个辅助函数 topi(),它除了接受驱动对象的旋转角度外,还将接受一个角度 h(以弧度为单位)和一个强度 i 作为参数。如果驱动对象的旋转角度在 0 到 h 之间,它将返回该强度,如果在这个范围之外,则返回零。因为输入角度可能大于两倍的 pi(当驱动对象旋转超过一整圈时),我们通过高亮的 modulo 操作进行纠正:
def topi(a,h,i):
m = a%(2*pi)
r=0.0
if m<h: r=i
return r
能量通道的 pydriver 表达式(在 IPO 编辑器窗口中称为 "Energ")可以表示如下:
p.topi(ob('DriveShaftPart').RotX/2+m.pi,0.3,0.5)
如所示,这个表达式将在其周期的前 17 度左右(约 0.3 弧度)通过将能量设置为 0.5 来触发火花塞。
更强大的功能——将多个气缸组合到发动机中
一旦我们建模了一个单缸并处理了各个部分的运动,我们的下一步就是复制气缸以创建类似于本章开篇插图中的集合。原则上我们可以选择所有部分,通过按 Shift + D 复制它们,并调整各个驱动通道的时序。
然而,有一个问题。当我们使用 Shift + D 而不是 Alt + D 时,我们实际上创建了对象网格的副本,而不是仅仅引用相同的对象。我们原本期望对于与对象相关联的其他项目,例如材质、纹理和 IPOs,也会有同样的效果。然而,事实并非如此,因为 Blender 默认情况下在复制对象时不会复制那些最后三个类别。例如,如果第一个活塞的 IPO 发生变化,将会影响所有复制的活塞,这将会很尴尬。
我们可以在之后使那些副本变得独特(例如,通过点击那些 IPO 的用户计数字段并确认 make single user? 弹出窗口)但这很麻烦,因为必须为每个副本重复操作。

更好的方法是修改用户首选项窗口中编辑方法屏幕的复制 与 对象设置,如前面的截图所示。这样,与对象关联的 IPOs 将在复制对象时变成独特的副本。上面显示了带有复制 IPOs 按钮(高亮显示)的用户首选项窗口的截图。
我们劳动的结果,一个带有齿轮的四个气缸发动机,可以将驱动轴的运动传递到凸轮轴上,现在可用作engine001.blend。下一张截图显示了动画中的静态图像,该动画可在vimeo.com/7170769找到:

添加简单约束
约束可以应用于对象以及骨骼。在这两种情况下,通过调用constraints属性的append()方法添加一个新的约束。我们的下一个示例将展示我们如何限制从绑定时钟(来自第三章, 顶点组和材质)的指针围绕 z 轴旋转。定义完成此操作的函数的代码以两个import语句开始,这将节省我们一些输入:
from Blender.Constraint import Type
from Blender.Constraint import Settings
函数本身将接受两个参数:obbones,一个指向 Blender 对象(其数据是骨架,即不是骨架对象本身)的引用,以及bone,我们想要限制其运动的骨骼名称。重要的是要理解,我们将与骨骼关联的约束不是骨架的属性,而是包含骨架的对象姿势的属性。许多对象可能引用同一个骨架,并且任何姿势都与对象相关联,因此引用相同骨架的不同对象可能会呈现不同的姿势。
因此,函数首先获取姿势,然后获取我们想要约束的骨骼的引用。高亮行显示了如何关联约束(如果我们将约束与 Blender 对象而不是骨骼关联,这将是类似的):
def zrotonly(obbones,bone):
poseob = obbones.getPose()
bigarmpose = poseob.bones[bone]
c=bigarmpose.constraints.append(Type.LIMITROT)
c[Settings.LIMIT]=Settings.LIMIT_XROT|Settings.LIMIT_YROT
c[Settings.XMIN]=0.0
c[Settings.XMAX]=0.0
c[Settings.YMIN]=0.0
c[Settings.YMAX]=0.0
poseob.update()
新添加的约束被保留为变量c,后续行显示可以像字典一样访问约束的不同属性。首先,我们配置LIMIT属性(一个位图)以限制 x 和 y 轴的旋转。接下来,我们将围绕这些轴的旋转的最小值和最大值设置为0.0,因为我们不允许任何移动。例如,在真实动物骨骼的绑定中,这些值可以设置为限制旋转范围,使其与真实关节相当。最后,为了使我们的Pose对象的变化可见,我们调用其update()方法。
定义复杂约束
当 pydrivers 使我们能够通过另一个IPOCurve的变化来驱动一个的变化时,PyConstraints 为我们提供了让对象属性仅以有限方式变化的方法。
当然,Blender 已经预定义了许多简单的约束,正如我们在前面的章节中看到的,通常简单约束的组合可能正是你想要的。但假设你希望你的对象在一个非矩形区域内自由移动,例如简化街道网格上交通灯和电话亭的允许放置。我们如何实现这一点?请进入 pyconstraints。
PyConstraints 是一些 Python 脚本,应该以文本块的形式存在于 Blender 的文本编辑器中,并以注释行开始,标识它为一个约束:
#BPYCONSTRAINT
一个 Python 约束应包含三个函数,称为doConstraint()、doTarget()和getSettings()。前两个函数在我们移动目标或约束对象时被调用,最后一个函数在用户点击选项按钮时被调用,该按钮在用户选择了一个 pyconstraint 之后出现。以下截图显示了选择 pyconstraint 后的约束选项卡。

理解这些函数的功能的最简单方法是通过查看内置的约束模板,我们可以将其用作编写我们自己的约束的基础。它可以通过文本编辑器的文本 | 脚本模板 | 脚本约束菜单访问。如果点击,它将在文本编辑器的底部下拉菜单中选择一个新文本块。
Blender 约束模板
Blender 约束模板也包含许多有用的注释,但在这里我们主要列出基本函数。此外,模板创建了一个虚拟属性窗口。我们将在下一节遇到属性,因此这里getSettings()的示例将几乎是空的。如图所示,这些函数将实现功能约束,然而,实际上没有任何东西被约束。约束对象的定位、旋转和缩放都保持不变。
def doConstraint(obmatrix, targetmatrices, idprop):
# Separate out the transformation components for easy access.
obloc = obmatrix.translationPart() # Translation
obrot = obmatrix.toEuler() # Rotation
obsca = obmatrix.scalePart() # Scale
# code to actually change location, rotation or scale goes here
# Convert back into a matrix for loc, scale, rotation,
mtxloc = Mathutils.TranslationMatrix(obloc)
mtxrot = obrot.toMatrix().resize4x4()
mtxsca = Mathutils.Matrix([obsca[0],0,0,0], [0,obsca[1],0,0],[0,0,obsca[2],0], [0,0,0,1])
# Recombine the separate elements into a transform matrix.
outputmatrix = mtxsca * mtxrot * mtxloc
# Return the new matrix.
return outputmatrix
doConstraint() 函数将传递约束对象的变换矩阵以及每个目标对象的变换矩阵列表。它还将接收一个包含约束属性字典,这些属性可以通过名称访问。
我们首先做的是将约束对象的变换矩阵的平移、旋转和缩放组件分离出来。平移部分将是一个包含 x、y、z 位置的向量,缩放部分将是一个包含沿 x、y 和 z 轴的缩放因子的向量。旋转部分将由一个表示绕三个主轴旋转的欧拉向量表示。(欧拉角大大简化了 3D 中的旋转操作,但一开始很难理解。维基百科有一个关于欧拉角的优秀页面en.wikipedia.org/wiki/Euler_angle,但就现在而言,最容易想到的是欧拉角作为绕局部 x、y 和 z 轴的旋转分离出来。)如果我们愿意,我们也可以将目标对象的任何变换矩阵分离出来,然后以任何我们希望的方式修改约束对象的变换矩阵的变换组件。
如此所示,该函数什么也不做,只是通过使用 API 方法(如果可用)将不同的变换组件转换回矩阵,然后通过矩阵乘法将它们重新组合成一个随后返回的单个矩阵。
在调用doConstraint()之前调用doTarget()函数,这给了我们操纵目标矩阵在传递给doConstraint()之前的机会。它的参数是目标对象、子目标(对于目标骨架或网格,分别是一个Bone或顶点组),目标矩阵和约束的属性。在后面的部分,我们将利用这个机会在属性中存储对目标对象的引用,以便doConstraint()可以访问它否则无法访问的信息。如果我们不想改变任何东西,那么像以下代码所示返回目标矩阵原样即可:
def doTarget(target_object, subtarget_bone, target_matrix,id_properties_of_constraint):
return target_matrix
同样,如果没有必要提供给用户指定额外属性的可能性,getSettings()可以简单地返回。如果我们确实想要显示一个弹出窗口,getSettings()就是它应该发生的地方。我们将在后面的部分看到一个例子。以下代码是一个有效的“什么都不做”实现:
def getSettings(idprop):
return
你也觉得我很有魅力吗?
当月亮和地球相互绕转时,它们会感受到彼此的引力。在地球上,这会导致潮汐,但地球和月球的固体也会发生形变,尽管这种效应很小。现在关于潮汐的不仅仅是引力(en.wikipedia.org/wiki/Tides),但我们可以通过应用约束来夸张地展示引力形变。
实现这一点的其中一种方法是通过使用TrackTo约束将我们约束对象的轴朝向吸引对象进行定位,并添加第二个约束来沿同一轴缩放约束对象。缩放的大小将取决于约束对象与目标对象之间的逆距离。效果如图所示,其中TrackTo约束的效果与脚本约束moon_constraint.py相结合。

我们将不得不自己编写这个距离相关的缩放。如果我们采用 Blender 提供的约束模板,我们可以保留doTarget()和getSettings()函数不变,但我们必须编写一个合适的doConstraint()函数(完整代码作为moon_constraint.py提供):
def doConstraint(obmatrix, targetmatrices, idprop):
obloc = obmatrix.translationPart() # Translation
obrot = obmatrix.toEuler() # Rotation
obsca = obmatrix.scalePart() # Scale
tloc = targetmatrices[0].translationPart()
d = abs((obloc-tloc).length)
d = max(0.01,d)
f = 1.0+1.0/d
obsca[1]*=f
mtxloc = Mathutils.TranslationMatrix(obloc)
mtxrot = obrot.toMatrix().resize4x4()
mtxsca = Mathutils.Matrix([obsca[0],0,0,0], [0,obsca[1],0,0],[0,0,obsca[2],0], [0,0,0,1])
outputmatrix = mtxsca * mtxrot * mtxloc
return outputmatrix
我们省略了与属性相关的任何行,因为我们没有为这个约束实现任何用户可配置的属性。高亮显示的行显示了我们需要做什么来计算距离相关的缩放。
第一行获取目标的位置。接下来,我们计算约束对象与目标之间的距离,并将其限制在一个最小值(略大于零)以防止在下一行高亮显示时发生除以零的情况。这里使用的公式远非任何引力影响的近似,但对我们目的来说已经足够好了;如果d非常大,则缩放因子将为1.0,并且随着距离d的减小将平滑增加。最后一行高亮显示的是我们只改变 y 轴的缩放,即我们使用TrackTo约束朝向目标对象定位的轴。
注意
循环依赖:
如果两个对象具有相当的质量,那么在两个对象上产生的引力扭曲将具有相当的大小。我们可能会想将TrackTo和moon_constraint.py约束添加到两个对象上,以观察它们对彼此产生的影响,但不幸的是,这不会起作用,因为它将创建循环依赖,Blender 将会抗议。
吸附到网格顶点
这类似于 Blender 菜单中的“吸附到顶点”模式,即对象 | 变换 | 吸附(更多关于吸附的信息,请参阅wiki.blender.org/index.php/Doc:Manual/Modelling/Meshes/Snap_to_Mesh)的功能,但效果不是永久的(一旦移除约束,对象将恢复到未约束的位置),并且可以通过改变影响滑块来调节约束的强度。
在我们迄今为止设计的约束中,只需要目标对象的当前位置来计算对约束对象的影响。这个位置对doConstraint()函数来说很容易获得,因为目标的矩阵作为参数传递。然而,我们现在面临一个不同的挑战:如果我们想要对齐到一个顶点,我们必须能够访问目标对象的网格数据,但目标对象并没有传递给doConstraint()函数。
克服这个障碍的方法是将idprop参数传递给doConstraint()。在调用doConstraint()之前,Blender 首先为每个目标对象调用doTarget()。这个函数作为目标对象和约束属性的引用传递。这允许我们在这些属性中插入目标对象的引用,因为这些属性传递给doConstraint(),这为我们提供了将必要信息传递给doConstraint()以获取Mesh数据的方法。这里有一个需要注意的小问题:Blender 属性只能是数字或字符串,所以我们实际上不能存储对象的引用,而只能满足其名称。因为名称是唯一的,Blender 的Object.Get()提供了一种通过名称检索对象的方法,所以这不是问题。
doConstraint()和doTarget()的代码如下(完整的代码作为zoning_constraint.py提供):
def doConstraint(obmatrix, targetmatrices, idprop):
obloc = obmatrix.translationPart().resize3D()
obrot = obmatrix.toEuler()
obsca = obmatrix.scalePart()
# get the target mesh
**to = Blender.Object.Get(idprop['target_object'])**
me = to.getData(mesh=1)
# get the location of the target object
tloc = targetmatrices[0].translationPart().resize3D()
# find the nearest vertex in the target object
smallest = 1000000.0
delta_ob=tloc-obloc
for v in me.verts:
d = (v.co+delta_ob).length
if d < smallest:
smallest=d
sv=v
obloc = sv.co + tloc
# reconstruct the object matrix
mtxrot = obrot.toMatrix().resize4x4()
mtxloc = Mathutils.TranslationMatrix(obloc)
mtxsca = Mathutils.Matrix([obsca[0],0,0,0], [0,obsca[1],0,0],[0,0,obsca[2],0], [0,0,0,1])
outputmatrix = mtxsca * mtxrot * mtxloc
return outputmatrix
def doTarget(target_object, subtarget_bone, target_matrix,id_properties_of_constraint):
** id_properties_of_constraint['target_object']=target_object.name**
return target_matrix
高亮行显示了我们将目标对象的名称传递给doConstraint()的方式。在doConstraint()中,我们首先检索目标网格。这可能抛出异常,例如,如果目标对象不是一个网格,但 Blender 本身会捕获这个异常。此时约束不会受到影响,并且控制台会显示错误,但 Blender 会继续愉快地运行。
一旦我们有了目标对象的网格数据,我们就检索目标对象的位置。我们需要这个位置,因为所有顶点坐标都是相对于这个位置。接下来,我们将约束对象的位置与目标网格的所有顶点位置进行比较,并记住最近的那个来计算约束对象的位置。最后,我们通过结合各种变换组件,像以前一样重建约束对象的变换矩阵。
沿着顶点法线对齐
现在我们可以将对象约束到目标网格上的最近顶点,我们可以看到有些东西缺失:对象没有以有意义的方式定向。这不一定总是问题,例如,树木通常向上生长,但在许多情况下,如果我们能够使约束对象垂直于表面定向会更好。这在所有实际目的上都是相同的,因为将约束对象沿其已对齐的顶点的顶点法线定向。
因此,在找到最近的顶点后,我们确定顶点法线与 z 轴之间的角度(即,我们任意定义 z 方向为'向上'),然后围绕垂直于顶点法线和 z 轴的轴旋转被约束的对象相同的角度。这将使被约束的对象沿着该顶点法线定位。如果被约束的对象在添加约束之前手动旋转,这些先前的旋转将会丢失。如果我们不希望这样,我们可以在添加约束之前永久应用任何旋转。
为了实现这个对齐功能,我们的代码将发生变化(zoning_constraint.py文件中已经包含了这些更改):doConstraint()将必须计算变换矩阵的旋转部分。我们必须计算旋转角度、旋转轴,然后是新的旋转矩阵。以下代码中突出显示的部分显示了这些计算的基本工具已经由Mathutils模块提供:
vnormal = sv.no
if idprop['NormalAlign'] :
zunit=Mathutils.Vector(0,0,1)
** a=Mathutils.AngleBetweenVecs(vnormal,zunit)**
** rotaxis=zunit.cross(vnormal)**
** rotmatrix=Mathutils.RotationMatrix(a,4,"r",rotaxis)**
mtxrot = rotmatrix
else:
mtxrot = obrot.toMatrix().resize4x4()
在前面的代码中,我们可以看到我们已经根据NormalAlign属性进行了对齐。只有当它被设置时,我们才会计算必要的变换。因此,我们还需要调整getSettings(),因为用户需要一种方式来选择是否想要对齐:
def getSettings(idprop):
if not idprop.has_key('NormalAlign'): idprop['NormalAlign'] = True
align = Draw.Create(idprop['NormalAlign'])
block = []
block.append("Additional restrictions: ")
block.append(("Alignment: ",align,"Align along vertex normal"))
retval = Draw.PupBlock("Zoning Constraint", block)
if (retval):
idprop['NormalAlign']= align.val
如图所示,NormalAlign属性将默认设置为True。然后,该选项将以一个简单的弹出窗口和切换按钮的形式呈现。如果用户点击弹出窗口外部或按下Esc键,PupBlock()的返回值将是None,我们不会更改NormalAlign属性。否则,它将被设置为切换按钮的值。
效果在插图中有展示。第一个展示了一棵小松树被约束在简单细分地面平面的一个顶点上。它被精确地吸附到顶点位置,但其 z 轴沿着全局 z 轴直指上方。接下来的截图显示了一棵云杉树被约束在崎岖景观中的一个顶点上。

如果我们打开NormalAlign属性,我们会看到树模型不再直指上方,而是其 z 轴沿着它被吸附到的顶点的顶点法线方向对齐。以下截图显示了一棵云杉树被约束在顶点上并沿着顶点法线对齐。

可以进一步限制模型可以吸附到的顶点,例如,仅限于属于顶点组的顶点。在以下插图,我们的模型不能移动到白色显示的顶点组范围之外。如何实现这一点将在下一节中展示。

吸附到顶点组的顶点上
如果我们想限制可以吸附的顶点?这可以通过定义一个顶点群,然后只考虑这个顶点群中的顶点作为吸附的候选。实现这一点的代码只需几行,doConstraint()的相关部分将看起来像这样(高亮代码显示了处理与顶点群匹配的附加行):
# get the target mesh
to = Blender.Object.Get(idprop['target_object'])
me = to.getData(mesh=1)
# get the location of the target object
tloc = targetmatrices[0].translationPart().resize3D()
# find the nearest vertex in the target object
smallest = 1000000.0
delta_ob=tloc-obloc
** try:**
** verts = me.getVertsFromGroup(idprop['VertexGroup'])**
** for vi in verts:**
** d = (me.verts[vi].co+delta_ob).length**
** if d < smallest :**
** smallest = d**
** si = vi**
** obloc = me.verts[si].co+tloc**
** vnormal = me.verts[si].no**
** except AttributeError:**
for v in me.verts:
d = (v.co+delta_ob).length
if d < smallest:
smallest=d
sv=v
obloc = sv.co + tloc
vnormal = sv.no
try/except结构确保如果VertexGroup属性引用了一个不存在的顶点群,我们将有机会检查所有顶点。当然,我们现在需要一种让用户选择顶点群的方法,因此getSettings()也需要进行适配。我们选择了一个简单的字符串输入字段,可以在其中输入顶点群的名字。没有检查顶点群是否存在,如果我们不想将吸附限制在顶点群上,我们既可以留空这个输入字段,也可以输入一个不存在的群组名字。这并不十分优雅,但它是有效的(添加的行已高亮):
def getSettings(idprop):
** if not idprop.has_key('VertexGroup'): idprop['VertexGroup'] ='Zone'**
if not idprop.has_key('NormalAlign'): idprop['NormalAlign'] = True
** vgroup = Draw.Create(idprop['VertexGroup'])**
align = Draw.Create(idprop['NormalAlign'])
block = []
block.append("Additional restrictions: ")
** block.append(("Vertex Group: ",vgroup,0,30,"Vertex Group torestrict location to"))**
block.append(("Alignment: ",align,"Align along vertex normal"))
retval = Draw.PupBlock("Zoning Constraint", block)
if (retval):
** idprop['VertexGroup']= vgroup.val**
idprop['NormalAlign']= align.val
下一张截图显示了顶点群输入框可能的样子:

注意
注意,脚本约束还向用户提供了一个可能引用顶点群的 VG 字符串输入字段,但这与我们向用户在选项弹出窗口中显示的顶点群输入字段不同。这个 VG 字段将改变约束查看目标的方式。如果在这里设置了有效的顶点群,传递给doConstraint()的目标矩阵将是顶点群中顶点中值位置的那个。
**# 摘要
在本章中,我们了解了如何将不同的动画属性相互关联,以及如何将物体的空间属性约束到复杂的限制中。我们学习了如何:
-
通过 Python 表达式从一个IPO驱动另一个
-
解决 pydrivers 中固有的某些限制
-
通过添加约束来限制物体和骨骼的运动**
-
编写一个 Python 约束,将物体吸附到另一个物体上最近的顶点上
接下来,我们将探讨如何在动画中每次前进一帧时执行某些操作。**
第五章。对帧变化进行操作
除了我们在 Blender 中遇到的所有可以使用 Python 的地方,我们现在将查看可能用于对某些事件进行操作的脚本。这些脚本有两种类型——脚本链接和空间处理器。
脚本链接是与 Blender 对象(网格、摄像机等,但还包括场景和世界对象)关联的脚本,并且可以设置在以下情况下自动运行:
-
在渲染帧之前
-
在渲染帧之后
-
当帧发生变化时
-
当对象被更新时
-
当对象数据被更新时
场景对象可能与其关联脚本链接,这些链接可能在以下两种情况下被调用:
-
在加载
.blend文件时 -
保存
.blend文件时
空间处理器是 Python 脚本,每次 3D 视图窗口重绘或检测到按键或鼠标动作时都会被调用。它们的主要用途是扩展 Blender 用户界面的功能。
在本章中,你将学习:
-
脚本链接和空间处理器是什么
-
如何在动画的每一帧变化上执行活动
-
如何将附加信息与对象关联
-
如何通过改变布局或改变透明度使对象出现或消失
-
如何实现一种方案,在每个帧上为对象关联不同的网格
-
如何增强 3D 视图的功能
对象的可见性动画
制作动画时经常遇到的一个问题是希望在某个帧使对象消失或淡出,要么是为了效果本身,要么是为了用另一个对象替换它以达到某种戏剧性的影响(如爆炸或兔子变成球)。
有许多方法可以构建这些效果,其中大多数并不是专门针对脚本链接在帧变化时做出反应的(许多可以简单地键入)。尽管如此,我们将查看两种可以轻松适应各种情况的技术,甚至包括那些不易键入的情况。例如,我们要求某个参数具有某种特定的行为,这在表达式中很容易制定,但在 IPO 中却难以捕捉。
渐变材料
我们的第一个例子将改变材料的漫反射****颜色。改变透明度同样简单,但通过插图更容易看到漫反射颜色的变化。
我们的目标是将漫反射颜色从黑色渐变到白色,然后再返回黑色,整个过程持续两秒钟。因此,我们定义了一个名为setcolor()的函数,它接受一个材料并改变其漫反射颜色(rgbColor属性)。它假设每秒 25 帧的帧率,因此第一行获取当前帧号并执行一个模除操作以确定当前整个秒中已经过去的时间比例。
在以下代码片段中,高亮行用于确定我们是否处于奇数或偶数秒。如果我们处于偶数秒,我们将漫反射颜色提升至白色,这样我们只需保留计算出的分数。如果我们处于奇数秒,我们将漫反射颜色降低至黑色,这样我们就从最大可能值(25)中减去分数。最后,我们将我们的值缩放到0到1之间,并将其分配给所有三个颜色分量,以获得一种灰色:
import Blender
def setcolor(mat):
s = Blender.Get('curframe')%25
if int(Blender.Get('curframe')/25.0)%2 == 0:
c = s
else:
c = 25-s
c /= 25.0
mat.rgbCol = [c,c,c]
if Blender.bylink and Blender.event == 'FrameChanged':
setcolor(Blender.link)
脚本以一个重要的检查结束:Blender.bylink仅在作为脚本处理程序调用此脚本时为True,在这种情况下Blender.event包含事件类型。我们只想对帧变化采取行动,因此这就是我们在这里检查的内容。如果这些条件得到满足,我们将Blender.link传递给我们的setcolor()函数,因为它包含我们的scriptlink脚本关联的对象——在这种情况下,那将是一个Material对象。(此脚本作为scriptlinks.blend中的MaterialScriptLink.py提供。)
我们接下来要做的事情是将脚本与我们要更改材质的对象关联起来。因此,我们选择该对象,在按钮 窗口中,我们选择脚本 面板。在脚本链接选项卡中,我们启用脚本链接并选择材质脚本链接按钮。(如果不存在材质脚本链接按钮,则所选对象没有分配任何材质。请确保它有。)现在应该有一个带有新按钮的标签选择 脚本 链接可见。点击新将显示一个下拉菜单,其中包含可用的脚本链接(文本编辑器中的文件)。在这种情况下,我们将选择MaterialScriptLink.py,我们就完成了。现在我们可以通过在 3D 视图中更改帧(使用箭头键)来测试我们的脚本链接。我们的对象颜色应该随着帧号的改变而改变。(如果颜色似乎没有改变,请检查 3D 视图中是否启用了实体或着色视图。)

改变层
如果我们想改变一个对象的可见性,改变它所分配的层是一个比改变材质属性更通用且强大的技术。例如,改变其分配的层有这样一个优点:我们可以使对象对于配置为仅照亮某些层的灯具完全不可见,以及动画的许多方面(例如,粒子被力场偏转)也可能仅限于某些层。此外,改变层不仅限于具有相关材质的对象。我们同样可以轻松地改变Lamp或Camera的层。
对于我们的下一个示例,我们希望如果经过的秒数是偶数,就将对象分配到层 1,如果是奇数,就分配到层 2。实现这个功能的脚本与我们更改材质的脚本非常相似。实际的工作是由setlayer()函数完成的。第一行计算对象在当前帧应该位于的层,下一行(突出显示)将包含单个层的层索引列表分配给对象的layers属性。setlayer()函数的最后两行确保层的更改在 Blender 中实际上是可见的。
import Blender
def setlayer(ob):
layer = 1+int(Blender.Get('curframe')/25.0)%2
ob.layers = [ layer ]
ob.makeDisplayList()
Blender.Window.RedrawAll()
if Blender.bylink and Blender.event == 'FrameChanged':
setlayer(Blender.link)
如同我们之前的脚本,我们脚本的最后几行检查我们是否作为脚本链接被调用,并且在一个帧改变事件发生时,如果是这样,就将相关对象传递给setlayer()函数。(脚本作为OddEvenScriptlink.py文件位于scriptlinks.blend中。)
剩下的工作是将脚本作为scriptlink分配给选定的对象。同样,这通过在按钮 窗口 | 脚本 面板中点击启用 脚本 链接在脚本链接选项卡(如果需要,可能仍然被选中,因为我们之前的示例。这是一个全局选择,即它对所有对象都是启用的或禁用的)。这次,我们选择对象scriptlinks而不是材质scriptlinks,并点击新建从下拉菜单中选择OddEvenScriptlink.py。
倒计时——使用脚本链接动画计时器
使用作用于帧改变的脚本链接的一种可能性是能够通过改变Mesh对象的顶点或与 Blender 对象关联一个完全不同的网格来修改实际的网格。当使用 IPOs 时这是不可能的,因为这些仅限于在具有相同网格拓扑(以相同方式连接相同数量的顶点)的预定义形状之间进行插值的形状键。对于曲线和文本对象也是如此。
该技术的应用之一是实现一个counter对象,它将显示动画开始以来的秒数。这是通过通过其setText()方法更改Text3d对象的文本来实现的。以下代码中的setcounter()函数正是这样做的,同时执行必要的操作来更新 Blender 的显示。(脚本作为CounterScriptLink.py文件位于scriptlinks.blend中。)
import Blender
objectname='Counter'
scriptname='CounterScriptLink.py'
def setcounter(counterob):
seconds = int(Blender.Get('curframe')/25.0)+1
counterob.getData().setText(str(seconds))
counterob.makeDisplayList()
Blender.Window.RedrawAll()
if Blender.bylink:
setcounter(Blender.link)
else:
countertxt = Blender.Text3d.New(objectname)
scn = Blender.Scene.GetCurrent()
counterob = scn.objects.new(countertxt)
setcounter(counterob)
counterob.clearScriptLinks([scriptname])
counterob.addScriptLink(scriptname,'FrameChanged')
此脚本可以像之前展示的那样与任何 Text3d 对象关联为脚本链接。然而,如果从文本编辑器以 Alt + P 运行,它将创建一个新的 Text3d 对象,并将其作为脚本链接与该对象关联。突出显示的行显示了如何检查这一点,就像在之前的脚本中一样,但在这个情况下,如果未作为脚本链接调用,我们也会采取一些行动(else 子句)。最后两个突出显示的行显示了如何将脚本与新创建的对象关联。首先,我们删除(清除)可能之前已关联的任何具有相同名称的脚本链接。这样做是为了防止将相同的脚本链接关联多次,虽然这是有效的,但几乎没有什么用处。接下来,我们将脚本添加为脚本链接,当帧发生变化时将被调用。截图显示了包含动画帧的 3D 视图以及列出脚本链接与对象关联的按钮窗口(左上角)。

注意
注意,尽管在 Python 脚本内部可以将脚本链接与 Blender 对象关联,但脚本链接必须手动启用才能实际运行!(在 ScriptLinks 选项卡中。)Blender Python API 中没有从脚本执行此操作的功能。
我会关注你
有时,当与复杂对象一起工作时,由于可能被几何形状的其他部分遮挡,很难跟踪相关功能。在这种情况下,以保持它们无论方向如何都可见的方式突出显示某些顶点会很好,并且独立于编辑模式。
空间处理程序为我们提供了一种方式,每次 3D 视图窗口重绘或检测到键或鼠标操作时执行操作。这些操作可能还包括在 3D 视图区域内绘制,因此我们可以在任何我们喜欢的位置添加高亮。
我们如何确定我们想要突出显示哪些顶点?Blender 已经为我们提供了一种统一的方式来将顶点集合分组为顶点组,所以我们只需要让用户指出他想要突出显示的顶点组即可。然后我们将所选顶点组的名称存储为对象属性。对象属性旨在在游戏引擎中使用,但没有理由我们不能将它们作为持久存储我们顶点组选择的一种方式。
因此,我们再次有一个脚本,它将以两种方式被调用:作为空间处理程序(即每次重绘 3D 视图窗口以突出显示我们的顶点)或通过从文本编辑器运行它,使用 Alt + P 提示用户选择要突出显示的顶点组。
代码概述:AuraSpaceHandler.py
下面的概述显示了在每种情况下我们将采取哪些步骤:
-
获取活动对象和网格。
-
如果独立运行:
-
获取顶点组列表
-
提示用户进行选择
-
将存储选择为对象的属性
-
-
否则:
-
获取包含顶点组的属性
-
获取顶点坐标列表
-
对于每个顶点:
- 绘制一个小圆盘
-
结果代码作为AuraSpaceHandler.py在scriptlinks.blend中可用:
# SPACEHANDLER.VIEW3D.DRAW
它以一条注释行开始,这是至关重要的,因为它向 Blender 发出信号,表明这是一个可以与 3D 视图关联的空间处理器脚本(目前其他区域不能关联空间处理器)并且应该在redraw事件上调用。
import Blender
from Blender import *
scn = Scene.GetCurrent()
ob = scn.objects.active
if ob.type == 'Mesh':
me = ob.getData(mesh = True)
if Blender.bylink:
p=ob.getProperty('Highlight')
vlist = me.getVertsFromGroup(p.getData())
matrix = ob.matrix
drawAuras([me.verts[vi].co*matrix for vi in vlist],p.getData())
else:
groups = ['Select vertexgroup to highlight%t']
groups.extend(me.getVertGroupNames())
result = Draw.PupMenu( '|'.join(groups) )
if result>0:
try:
p=ob.getProperty('Highlight')
p.setData(groups[result])
except:
ob.addProperty('Highlight',groups[result])
然后脚本继续从当前场景中检索活动对象,如果它是Mesh,则获取对象的网格。在突出显示的行,我们检查是否作为空间处理器运行,如果是,则检索我们命名为Highlight的属性。该属性的数据是我们想要突出显示的顶点组的名称。我们接着获取该顶点组中所有顶点的列表以及对象的矩阵。我们需要这个矩阵,因为顶点位置是相对于对象的矩阵存储的。然后我们构建一个顶点位置的列表,并将这个列表以及顶点组的名称传递给drawAuras()函数,该函数将负责实际的绘制。
第二条突出显示的行标志着当我们从文本编辑器运行脚本时将要执行的代码的开始。它创建一个由与活动对象关联的所有顶点组名称组成的字符串,名称之间用管道字符(|)分隔,并前面有一个合适的标题。这个字符串传递给PopMenu(),它将显示菜单,并返回用户的选项或-1,如果没有选择任何内容。
如果选择了顶点组,我们尝试检索Highlight属性。如果成功,我们将其数据设置为所选顶点组的名称。如果该属性尚不存在,我们添加一个新的属性,名称为Highlight,并再次以所选顶点组的名称作为数据。
接下来,我们必须确保scriptlinks已启用(按钮 窗口 | 脚本 面板 | 脚本链接。如果尚未选择,请点击启用 脚本链接)。请注意,对于 Blender 来说,无论我们处理的是空间处理器还是脚本链接,只要启用它们,就没有区别。
使用我们的空间处理器最后的步骤是将它与 3D 视图关联起来。为此,在 3D 视图的视图(空间 处理器 脚本菜单中)切换Draw: AuraSpaceHandler.py条目。

使用主题
我们尚未看到的代码处理的是实际绘制高亮显示和用于标识我们正在突出显示的顶点组的名称。它首先通过从当前主题中检索这些信息来确定我们将用于高亮显示和文本的颜色。这样,用户就可以从用户 首选项窗口以方便的方式自定义这些颜色:
theme = Window.Theme.Get()[0]
textcolor = [float(v)/255 for v in theme.get(Window.Types.VIEW3D ).text_hi[:3]]
color = [float(v)/255 for v intheme.get(Window.Types.VIEW3D).active[:3]]
第一行将检索一个包含主题的列表。第一个是活动主题。从这个主题中,我们检索VIEW3D主题空间及其text_hi属性,它是一个表示 RGBA 颜色的四个整数的列表。列表推导式丢弃了 alpha 组件,并将其转换为范围在[0, 1]内的三个浮点数的列表,我们将使用它作为我们的文本颜色。同样,我们构造高亮颜色来自active属性。
我们接下来的挑战是在指定位置绘制一个圆形高亮。由于圆盘的大小相当小(可以通过修改size变量进行调整),我们可以用八边形形状很好地近似它。我们将此类八边形的 x,y 坐标列表存储在diskvertices列表中:
size=0.2
diskvertices=[( 0.0, 1.0),( 0.7, 0.7),( 1.0, 0.0),( 0.7,-0.7),( 0.0,-1.0),(-0.7,-0.7),(-1.0, 0.0),(-0.7, 0.7)]
def drawDisk(loc):
BGL.glBegin(BGL.GL_POLYGON)
for x,y in diskvertices:
BGL.glVertex3f(loc[0]+x*size,loc[1]+y*size,loc[2])
BGL.glEnd()
实际绘制八边形严重依赖于 Blender 的BGL模块提供的函数(在之前的代码中突出显示)。我们首先声明我们将绘制一个多边形,然后为diskvertices列表中的每个元组添加一个顶点。传递给drawDisk()的位置将是中心,所有顶点都将位于半径等于size的圆上。当我们调用glEnd()函数时,填充的多边形将以当前颜色绘制。
你可能会想知道这些绘图函数是如何知道如何将 3D 位置转换为屏幕坐标的,实际上这里的情况比表面看起来要复杂得多,我们将在下一节代码中看到。将 3D 坐标转换为屏幕坐标所需的功能调用并未在drawDisk()函数(前面的代码片段)中实现。这是因为为每个单独的圆盘计算这些信息将导致不必要的性能损失,因为每个我们绘制的圆盘的信息都是相同的。
因此,我们定义了一个函数drawAuras(),它将接受一个位置列表和一个groupname参数(一个字符串)。它将计算变换参数,为列表中的每个位置调用drawDisk(),然后在高亮显示的右侧大约位置添加组名作为屏幕标签。Blender 的Window模块为我们提供了GetPerspMatrix()函数,该函数将检索将 3D 空间中的点正确转换为屏幕上的点的矩阵。这个 4x4 矩阵是一个 Python 对象,必须将其转换为单个浮点数列表,以便由图形系统使用。以下代码中突出显示的行负责处理此事。接下来的三行重置投影模式,并告诉图形系统使用我们适当转换的透视矩阵来计算屏幕坐标。请注意,更改这些投影模式和其他图形设置不会影响 Blender 本身在屏幕上绘制的内容,因为这些设置在调用我们的脚本处理程序之前被保存,之后被恢复:
def drawAuras(locations,groupname):
viewMatrix = Window.GetPerspMatrix()
viewBuff = [viewMatrix[i][j] for i in xrange(4) for j in xrange(4)]
viewBuff = BGL.Buffer(BGL.GL_FLOAT, 16, viewBuff)
BGL.glLoadIdentity()
BGL.glMatrixMode(BGL.GL_PROJECTION)
BGL.glLoadMatrixf(viewBuff)
BGL.glColor3f(*color)
for loc in locations:
drawDisk(loc)
n=len(locations)
if n>0:
BGL.glColor3f(*textcolor)
x=sum([l[0] for l in locations])/n
y=sum([l[1] for l in locations])/n
z=sum([l[2] for l in locations])/n
BGL.glRasterPos3f(x+2*size,y,z)
Draw.Text(groupname,'small')
在完成初步计算后,我们可以使用glColor3f()函数设置我们将用其绘制的圆盘的颜色。因为我们存储颜色为一个包含三个浮点数的列表,而glColor3f()函数需要三个单独的参数,所以我们使用星号运算符解包这个列表。接下来,我们对locations中的每个项目调用drawDisk()。
注意
Blender OpenGL 函数:
Blender 的BGL模块文档列出了来自OpenGL库的大量函数。其中许多函数有多种变体,执行相同的操作但以不同的方式接收参数。例如,BGL.glRasterPos3f()与BGL.glRasterPos3fv()密切相关,后者将接受一个包含三个单精度浮点值的列表而不是三个单独的参数。更多信息,请参阅Blender.BGL和Blender.Draw模块的 API 文档以及 OpenGL 参考手册www.opengl.org/sdk/docs/man/。
如果我们绘制的突出显示的数量不是零,我们将绘图颜色设置为textcolor,然后计算所有突出显示的平均坐标。然后我们使用glRasterPos3f()函数将我们要绘制的文本的起始位置设置为这些平均坐标,并在 x 坐标上添加一些额外空间以使文本稍微向右偏移。Blender 的Draw.Text()函数将在所选位置以小字体绘制组名。
重新审视网格——留下印象
尽管 Blender 中可用的软体和布料模拟器在许多情况下都能出色地完成任务,但有时你可能希望对网格变形的方式有更多的控制,或者模拟一些 Blender 内置模拟器未涵盖的特定行为。本练习展示了如何计算一个网格在受到另一个网格接触但未穿透时的变形。这不是为了达到物理上的精确,目的是为了在固体物体接触一个容易变形或粘稠的表面(如手指舔黄油或车轮穿过柔软的路面)时,给出令人信服的结果。
以下插图展示了可能实现的效果。轨道是通过在一个细分平面上动画一个滚动汽车轮胎来创建的:

在以下部分,我们将被变形的对象网格称为源,执行变形的对象或对象称为目标。从某种意义上说,这就像一个约束,我们可能会将这些变形实现为 pyconstraints。然而,这并不可行,因为约束会在源或目标移动时每次都进行评估;因此,当计算交点和网格的变形结果时,这会导致用户界面陷入停滞,因为计算量很大。因此,我们选择了一种方法,每次帧改变时都计算和缓存结果。
我们的脚本将需要执行几个功能,它必须:
-
在每个帧改变时计算和缓存变形
-
当存在缓存信息时更改顶点坐标
当作为独立脚本运行时,脚本应该:
-
保存或恢复原始网格
-
提示用户选择目标
-
将自己作为脚本链接与源对象关联
-
可能会移除作为脚本链接的自己
在设计脚本时,一个重要的考虑因素是我们将如何存储或缓存原始网格和中间变形网格。因为我们不会改变网格的拓扑结构(即顶点相互连接的方式),只是顶点坐标,所以只需存储这些坐标就足够了。这让我们面临一个问题:在哪里存储这些信息。
如果我们不希望编写自己的持久化存储解决方案,我们有两个选择:
-
使用 Blender 的注册表
-
将数据与源对象关联为属性
Blender 的注册表易于使用,但我们必须有一种方法将数据与对象关联起来,因为用户可能希望将多个对象与印象计算关联起来。我们可以使用对象的名称作为键,但如果用户更改该名称,我们就会失去与存储信息的引用,而脚本链接功能仍然存在。这将使用户负责在对象名称更改时删除存储的数据。
将所有数据关联为属性不会受到任何重命名的影响,并且当对象被删除时,数据将被清除,但可以存储在属性中的数据类型仅限于整数、浮点值或字符串。可以通过使用 Python 的标准pickle模块将任意数据转换为字符串,但不幸的是,这个场景被两个问题所阻碍:
-
Blender 中的顶点坐标是
Vector实例,这些实例不支持 pickle 协议 -
字符串属性的大小限制为 127 个字符,这对于存储即使是中等大小的网格的单个顶点坐标帧来说也太小了。
尽管使用注册表的缺点,我们仍将使用它来设计两个函数——一个用于存储给定帧号的顶点坐标,另一个用于检索该数据并将其应用于网格的顶点。首先,我们定义一个实用函数ckey(),它将根据我们想要缓存的网格数据的对象名称返回一个用于注册表函数的键:
def ckey(ob):
return meshcache+ob.name
注意
并非所有注册表都相同
不要混淆 Blender 的注册表与 Windows 注册表。它们都服务于类似的目的,即为各种数据提供持久存储,但它们是不同的实体。Blender 注册表项的实际数据默认存储在.blender/scripts/bpydata/config/,并且可以通过设置datadir属性使用Blender.Set()来更改此位置。
我们的storemesh()函数将接受一个对象和一个帧号作为参数。它的第一个动作是从与对象关联的网格数据中提取仅顶点坐标。接下来,它检索我们正在处理的对象在 Blender 注册表中存储的任何数据,并且我们传递额外的True参数来指示如果内存中没有数据,GetKey()应在磁盘上检查它。如果我们的对象没有任何存储的数据,GetKey()将返回None,在这种情况下,我们将我们的缓存初始化为一个空字典。
随后,我们将我们的网格坐标存储在这个字典中,以帧号作为索引(在下一代码片段中突出显示)。我们把这个整数帧号转换成字符串,用作实际的键,因为 Blender 的SetKey()函数在将注册表数据保存到磁盘时假定所有键都是字符串,如果遇到整数将引发异常。最后一行再次调用SetKey(),并带有额外的True参数来指示我们希望数据也存储到磁盘上。
def storemesh(ob,frame):
coords = [(v.co.x,v.co.y,v.co.z) for v in ob.getData().verts]
d=Blender.Registry.GetKey(ckey(ob),True)
if d == None: d={}
d[str(frame)]=coords
Blender.Registry.SetKey(ckey(ob),d,True)
retrievemesh()函数将接受一个对象和一个帧号作为参数。如果它为给定对象和帧找到了缓存的数据,它将把存储的顶点坐标分配给网格中的顶点。我们首先定义两个新的异常来指示retrievemesh()可能遇到的一些特定错误条件:
class NoSuchProperty(RuntimeError): pass;
class NoFrameCached(RuntimeError): pass;
如果对象没有关联的缓存网格数据,retrievemesh()将引发NoSuchProperty异常;如果数据存在但不是针对指示的帧,将引发NoFrameCached异常。下一代码中高亮显示的行值得注意。我们使用mesh=True获取对象的关联网格数据。这将产生一个包装的网格,而不是副本,因此我们访问或修改的任何顶点数据都将引用实际数据。此外,我们遇到了 Python 的内置zip()函数,它将接受两个列表并返回一个由两个元素的元组组成的列表,每个元素来自每个列表。它有效地让我们并行遍历两个列表。在我们的情况下,这些列表是顶点列表和坐标列表,我们只需将这些坐标转换为向量并将它们分配给每个顶点的 co-属性:
def retrievemesh(ob,frame):
d=Blender.Registry.GetKey(ckey(ob),True)
if d == None:
raise NoSuchProperty("no property %s for object %s"
%(meshcache,ob.name))
try:
coords = d[str(frame)]
except KeyError:
raise NoFrameCached("frame %d not cached on object %s"
%(frame,ob.name))
for v,c in zip(ob.getData(mesh=True).verts,coords):
v.co = Blender.Mathutils.Vector(c)
为了完成我们的缓存函数集,我们定义了一个clearcache()函数,该函数将尝试删除与我们的对象关联的注册数据。try … except …子句将确保存储数据的缺失被静默忽略:
def clearcache(ob):
try:
Blender.Registry.RemoveKey(ckey(ob))
except:
pass
用户界面
我们的脚本不仅将被用作与对象关联的脚本链接,而且还可以独立使用(例如,在文本编辑器中按Alt + P),为用户提供识别将清除缓存的目标以及将脚本链接与活动对象关联的手段。如果以这种方式使用,它将向最终用户展示几个弹出菜单,如截图所示。第一个显示了可能的操作:

第二张截图显示了提供给用户从Mesh对象列表中选择对象的弹出菜单:

我们首先定义一个效用函数,该函数将由弹出菜单使用,向用户提供选择用作印象目标的Mesh对象。getmeshobjects()函数将接受一个scene参数,并返回所有Mesh对象名称的列表。如图表所示,目标对象列表包括源对象。尽管这是合法的,但关于这非常有用性是有争议的:
def getmeshobjects(scene):
return [ob.name for ob in scene.objects if ob.type=='Mesh']
菜单本身是通过以下定义的targetmenu()函数实现的:
def targetmenu(ob):
meshobjects=getmeshobjects(Blender.Scene.GetCurrent())
menu='Select target%t|'+ "|".join(meshobjects)
ret = Blender.Draw.PupMenu(menu)
if ret>0:
try:
p = ob.getProperty(impresstarget)
p.setData(meshobjects[ret-1])
except:
ob.addProperty(impresstarget,meshobjects[ret-1])
它将获取场景中所有网格对象的列表,并通过使用 Blender 的Draw.PupMenu()函数将此列表作为选择展示给用户。如果用户选择菜单条目之一(返回值将是正数且非零,参见前述代码中的高亮行),它将存储此网格对象的名称作为与我们的对象关联的属性。impresstarget在别处定义为属性的名称。首先,代码通过调用getProperty()方法并设置属性数据来检查是否已经与对象关联了此类属性。如果getProperty()由于属性尚不存在而引发异常,我们随后将新属性添加到对象中,并通过对addProperty()方法的单一调用分配数据。
主要用户界面在脚本的顶层定义。它验证它不是作为脚本链接运行的,然后向用户展示一系列选择:
if not Blender.bylink:
ret = Blender.Draw.PupMenu('Impress scriptlink%t|Add/Replace' +'scriptlink|Clear cache|Remove' + 'all|New Target')
active = Blender.Scene.GetCurrent().objects.active
if ret > 0:
clearcache(active)
if ret== 1:
active.clearScriptLinks([scriptname])
active.addScriptLink(scriptname,'FrameChanged')
targetmenu(active)
elif ret== 2:
pass
elif ret== 3:
active.removeProperty(meshcache)
active.clearScriptLinks([scriptname])
elif ret== 4:
targetmenu(active)
任何有效的选择都将清除缓存(高亮显示)并且后续的检查将执行与每个单独选择相关的必要操作:添加/替换脚本链接将删除(如果已存在)脚本链接以防止重复,然后将其添加到活动对象中。然后显示目标菜单以选择用于制作印痕的网格对象。由于我们已经清除了缓存,第二个选择清除缓存将不会执行任何特定操作,所以我们只需跳过。全部删除将尝试删除缓存并尝试解除自身作为脚本链接的关联,最后的新目标菜单将显示目标选择菜单,允许用户选择新的目标对象而不删除任何缓存结果。
如果我们作为脚本链接运行,我们首先检查我们是否正在处理一个FrameChanged事件,然后尝试检索当前帧(在下述代码中高亮显示)存储的任何存储的顶点坐标。如果没有先前存储的数据,我们必须计算此帧的目标对象的效果。因此,我们通过调用实用函数gettargetobjects()(目前将返回一个仅包含一个对象的列表)来获取考虑中的对象的目标对象列表,并对每个对象通过调用impress()来计算对我们网格的影响。然后,我们存储这些可能已更改的顶点坐标并更新显示列表,以便 Blender GUI 知道如何显示我们修改后的网格:
elif Blender.event == 'FrameChanged':
try:
retrievemesh(Blender.link,Blender.Get('curframe'))
except Exception as e: # we catch anything
objects = gettargetobjects(Blender.link)
for ob in objects:
impress(Blender.link,ob)
storemesh(Blender.link,Blender.Get('curframe'))
Blender.link.makeDisplayList()
这就留下了我们在我们的网格上计算目标对象印痕的实际计算。
计算印痕
当确定制作印痕的目标对象的效果时,我们将如下处理:
对于接收印痕的网格中的每个顶点:
-
确定它是否位于目标对象内部,如果是的话:
-
将顶点的位置设置为制作印痕的对象上最近的顶点位置
这里有一些重要的问题需要解决。网格中顶点的位置是相对于对象的变换矩阵存储的。换句话说,如果我们想要比较两个不同网格中的顶点坐标,我们必须在比较之前通过各自的变换矩阵变换每个顶点。
此外,Blender.Mesh对象有一个pointInside()方法,如果给定点在网格内,它将返回True。然而,这仅在封闭网格上才能可靠地工作,因此用户必须验证将要形成印象的对象实际上是否是封闭的。(它们可能有内部气泡,但它们的表面不得包含不是恰好由两个面共享的边。这些所谓的非流形边可以在边 选择模式下通过在 3D 视图中选择选择 | 非流形或按Ctrl + Shift + Alt + M来选择。)
最后,当目标网格相当粗糙时,将顶点移动到目标对象上的最近顶点可能非常不准确。然而,从性能的角度来看,拥有相对较少的点是有好处的——因为我们的算法相当低效,因为它首先确定一个点是否在网格内,然后单独计算最近的顶点副本重复了大量的计算。然而,由于性能对于由数百个点组成的网格也是可接受的,我们坚持我们的方法,因为它使我们的代码简单,并节省了我们编写和测试非常复杂的代码。
实现从返回给定点pt最近的顶点的距离和坐标的函数开始:
def closest(me,pt):
min = None
vm = None
for v in me.verts:
d=(v.co-pt).length
if min == None or d<min:
min = d
vm = v.co
return min,vm
impress()函数本身接受源对象和目标对象作为参数,如果目标网格形成印象,它将修改源对象的网格数据。它首先做的事情是检索对象的变换矩阵。如前所述,这些将用于变换顶点的坐标,以便进行比较。我们还检索源对象的逆矩阵。这将被需要以将坐标转换回源对象的空间。
突出的行检索源对象的包裹网格数据。我们需要包裹数据,因为我们可能想要更改一些顶点坐标。接下来的两行检索网格数据的副本。我们也需要副本,因为我们将要执行的变化可能不会影响实际的网格数据。我们本可以省略mesh=True参数,这样就会得到一个Nmesh对象的引用而不是Mesh对象。然而,Nmesh对象不是包裹的,并且已被标记为弃用。此外,它们缺少我们需要的pointInside()方法,所以我们选择自己复制网格。
接下来,我们使用各自的物体变换矩阵对这些网格副本进行变换。使用这些网格的transform()方法可以避免我们逐个顶点迭代并自己乘以变换矩阵,而且这个方法可能比手动计算更快,因为transform()方法完全是用 C 语言实现的:
from copy import copy
def impress(source,target):
srcmat=source.getMatrix()
srcinv=source.getInverseMatrix()
tgtmat=target.getMatrix()
orgsrc=source.getData(mesh=True)
mesrc=copy(source.getData(mesh=True))
metgt=copy(target.getData(mesh=True))
mesrc.transform(srcmat)
metgt.transform(tgtmat)
for v in mesrc.verts:
if metgt.pointInside(v.co):
d,pt = closest(metgt,v.co)
orgsrc.verts[v.index].co=pt*srcinv
impress()函数的最后一部分遍历变换后的源网格中的所有顶点,并检查顶点是否位于(变换后的)目标网格内部。如果是,它将确定目标网格上最近的顶点,并将原始网格中受影响的顶点设置为这些坐标。
这个原始网格没有进行变换,因此我们必须通过乘以逆变换矩阵将这个最近点转换回源对象的物体空间。因为变换计算成本较高,修改变换后的网格并在最后将整个网格完全转换回来可能需要相当多的时间。因此,当只有相对少数顶点受到印象影响时,保留未变换网格的引用并仅变换个别点可能更可取。完整的脚本作为ImpressScriptLink.py在scriptlinks.blend中可用。以下插图显示了可能的情况。在这里,我们制作了一个球(一个二十面体球体)沿着并滚入泥中(一个细分平面)的小动画。

当使用脚本时,重要的是要记住,当计算印象时,接收印象的网格的任何顶点都不应该在移动之前位于目标内部。如果发生这种情况,一个顶点可能会随着目标的移动而被卷走,从而在移动过程中扭曲源网格。例如,为了制作泥地中车轮轨迹的插图,我们沿着路径动画化一个滚动的车轮,计算它在每一帧上留下的印象。在我们动画的第一帧中,我们应该确保车轮没有接触到将被扭曲的地面平面,因为如果地面平面的一个顶点在车轮内部并且靠近内缘,它将被移动到那个内缘上最近的顶点。如果车轮滚动得慢,这个顶点将保持在那个内缘附近,从而有效地粘附在那个移动的内缘上,在这个过程中撕裂地面平面。如果目标对象与源网格相比非常小或者移动得非常快,也可能发生这种破坏性的过程。在这种情况下,一个顶点可能会以如此快的速度穿透目标对象,以至于最近的顶点不会在导致印象的前导表面上,而是在目标的另一个地方,这会导致顶点被向外拉而不是向内推。在滚动拖拉机轮胎的插图示例中,我们在对向左的滚动运动进行关键帧动画之前,小心地将轮胎定位在第一帧中,使其位于细分平面的右侧。所显示的图片是在第 171 帧拍摄的,没有对平面应用任何平滑或材质。

摘要
在本章中,我们学习了如何将变化链接到动画帧的进度,以及如何将状态信息与对象关联。我们还看到了如何更改图层,例如使对象不可见。具体来说,我们看到了:
-
脚本链接和空间处理器是什么
-
如何在动画的每一帧变化时执行活动
-
如何将附加信息与对象关联
-
如何通过更改图层或更改其透明度使对象出现或消失
-
如何实现一个方案,将不同的网格与每一帧上的对象关联
-
如何增强 3DView 的功能
接下来:添加形状键和 IPOs。
第六章。形状关键点,IPOs 和姿态
我们在第四章中遇到了 IPOs,Pydrivers 和 Constraints,当我们讨论 Pydrivers 时,但 IPOs 不仅仅是通过另一个 IPO 来驱动一个IPO。例如,Blender API 为我们提供了从头定义 IPO 的手段,使我们能够定义手动设置关键帧难以轻松复制的运动。此外,某些类型的 IPO 的行为与迄今为止我们所遇到的不同。形状关键点和姿态是(集合)IPO 的例子,与例如位置 IPO 相当不同。我们将在本章后面遇到形状关键点和姿态,但我们将从探讨如何从头开始定义 IPO 开始。
在本章中,你将学习如何:
-
定义 IPOs
-
在网格上定义形状关键点
-
定义那些形状关键点的 IPO。
-
定位骨架
-
将姿态变化分组到动作中
一个棘手的话题——从头开始定义 IPO
许多物体的运动路径很难手动建模,例如,当我们想要物体遵循精确的数学曲线或想要以不是通过复制 IPO 或定义 IPO 驱动器的方式协调多个物体的运动时。
想象以下场景:我们希望在一段时间内以流畅的方式交换某些物体的位置,而这些物体在中间不会相互穿过,甚至不会相互接触。这可能通过手动设置关键帧来实现,但这也很繁琐,尤其是如果我们想要为几组物体重复这样做。我们将设计的脚本将处理所有这些细节,并且可以应用于任何两个物体。
代码概要:orbit.py
我们将设计的orbit.py脚本将采取以下步骤:
-
确定所选物体之间的中点。
-
确定所选物体的范围。
-
定义第一个物体的 IPO。
-
定义第二个物体的 IPO。
确定所选物体之间的中点很容易:我们只需取两个物体的平均位置。确定所选物体的范围则有点更具挑战性。一个物体可能具有不规则形状,并且计算物体沿其将采取的路径的任何旋转的最短距离是困难的。幸运的是,我们可以做出合理的近似,因为每个物体都有一个相关的边界****框。
这个边界框是一个矩形框,刚好包围了一个对象的所有点。如果我们取体对角线的一半作为对象的范围,那么很容易看出这个距离可能是对我们如何接近另一个对象而不接触的一种夸张,这取决于对象的精确形状。但它将确保我们永远不会太靠近。这个边界框可以通过对象的 getBoundBox() 方法作为一个包含八个向量的列表轻松获得,每个向量代表边界框的一个角。这个概念在以下图中得到了说明,其中显示了两个球体的边界框:

矩形框的体对角线长度可以通过确定每个 x、y 和 z 坐标的最大值和最小值来计算。表示该体对角线的向量的分量是这些最大值和最小值之间的差异。通过对 x、y 和 z 分量的平方和开平方,随后得到对角线的长度。diagonal() 函数是一个相当简洁的实现,因为它使用了 Python 的许多内置函数。它接受一个向量列表作为参数,然后遍历每个分量(突出显示。Blender Vector 的 x、y 和 z 分量分别可以通过 0、1 和 2 访问):
def diagonal(bb):
maxco=[]
minco=[]
for i in range(3):
maxco.append(max(b[i] for b in bb))
minco.append(min(b[i] for b in bb))
return sqrt(sum((a-b)**2 for a,b in zip(maxco,minco)))
它通过使用内置的 max() 和 min() 函数来确定每个分量的极端值。最后,它通过使用 zip() 函数将每个最小值和最大值配对来返回长度。
下一步是验证我们是否恰好选择了两个对象,如果不是这种情况,则通过弹出窗口(在下一代码片段中突出显示)通知用户。如果我们确实选择了两个对象,我们检索它们的位置和边界框。然后我们计算每个对象必须偏离其路径的最大距离 w,使其是它们之间最小距离的一半,这等于这些对象体对角线长度之和的四分之一:
obs=Blender.Scene.GetCurrent().objects.selected
if len(obs)!=2:
Draw.PupMenu('Please select 2 objects%t|Ok')
else:
loc0 = obs[0].getLocation()
loc1 = obs[1].getLocation()
bb0 = obs[0].getBoundBox()
bb1 = obs[1].getBoundBox()
w = (diagonal(bb0)+diagonal(bb1))/4.0
在我们能够计算两个对象的轨迹之前,我们首先创建两个新的空 Object IPO:
ipo0 = Ipo.New('Object','ObjectIpo0')
ipo1 = Ipo.New('Object','ObjectIpo1')
我们任意选择我们的交换操作的开始和结束帧分别为 1 和 30,但脚本可以很容易地修改为提示用户输入这些值。我们遍历每个单独的 Location IPO 曲线,并为曲线分配一个元组 (framenumber, value) 以创建第一个点(或关键帧),从而创建实际的曲线(突出显示的下一行代码)。可以通过按帧号索引这些曲线来添加后续的点,就像在以下代码中对帧 30 所做的那样:
for i,icu in enumerate((Ipo.OB_LOCX,Ipo.OB_LOCY,Ipo.OB_LOCZ)):
ipo0[icu]=(1,loc0[i])
ipo0[icu][30]=loc1[i]
ipo1[icu]=(1,loc1[i])
ipo1[icu][30]=loc0[i]
ipo0[icu].interpolation = IpoCurve.InterpTypes.BEZIER
ipo1[icu].interpolation = IpoCurve.InterpTypes.BEZIER
注意,第一个物体在帧 1 的关键帧位置是其当前位置,而帧 30 的关键帧位置是第二个物体的位置。对于其他物体,情况正好相反。我们将这些曲线的插值模式设置为“贝塞尔”,以获得平滑的运动。现在我们有两个 IPO 曲线,它们确实会交换两个物体的位置,但按照计算,它们将直接穿过对方。
因此,我们的下一步是在帧 15 添加一个带有调整后的 z 分量的关键帧。之前,我们计算了w来保持彼此之间的距离。现在,我们将这个距离加到第一个物体的中点 z 分量上,并从另一个物体中减去它:
mid_z = (loc0[2]+loc1[2])/2.0
ipo0[Ipo.OB_LOCZ][15] = mid_z + w
ipo1[Ipo.OB_LOCZ][15] = mid_z - w
最后,我们将新的 IPO 添加到我们的对象中:
obs[0].setIpo(ipo0)
obs[1].setIpo(ipo1)
完整的代码作为swap2.py文件存储在orbit.blend文件中。两个物体的结果路径在下一张截图中有展示:

定义姿势有很多要消化
许多卡通角色似乎在尝试吞咽食物时遇到困难,即使他们享受了一顿轻松的午餐,也很可能他们会被迫通过一个太小而无法舒适通过的雨管。
使用形状键来动画化吞咽或其他蠕动运动是困难的,因为整体网格的形状并不是均匀变化的:我们希望沿着局部变形移动。实现这一点的办法之一是将由线性骨骼链组成的骨架与我们要变形的网格(如图所示)关联起来,并在时间上动画化每个单独骨骼的缩放。这样,我们可以极大地控制内部“块”的运动。例如,我们可以使运动稍微停顿,从一根骨骼移动到另一根,以模拟难以吞咽的东西。

为了以从父级到子级的顺序同步单个骨骼的缩放,我们必须对骨骼进行排序,因为当我们对骨架调用getPose()时得到的Pose对象的bones属性是一个字典。遍历这个字典的键或值将返回随机的值。
因此,我们定义了一个函数sort_by_parent(),它将接受一个Pose骨骼列表pbones,并返回一个字符串列表,每个字符串都是一个Pose骨骼的名称。列表按照父级作为第一个项目,然后是其子项进行排序。显然,对于具有多个子项的骨骼的骨架,这个列表将没有意义,但对我们线性链骨骼来说,它工作得很好。
在以下代码中,我们维护一个名为bones的名称列表,其中包含Pose骨骼的正确顺序。我们弹出Pose骨骼的列表,只要它尚未添加(如下突出显示),我们就添加Pose骨骼的名称。我们比较名称而不是Pose骨骼对象,因为当前Pose骨骼的实现并没有可靠地实现in运算符:
def sort_by_parent(pbones):
bones=[]
if len(pbones)<1 : return bones
bone = pbones.pop(0)
while(not bone.name in bones):
bones.append(bone.name)
然后,我们获取我们刚刚添加到列表中的骨骼的父级,只要我们可以遍历父级链,我们就在当前项之前(如下突出显示)将此父级(或者更确切地说,它的名称)插入我们的列表中。如果链不能再跟随,我们将弹出一个新的Pose骨骼。当没有骨骼剩下时,pop()方法将引发IndexError异常,我们将退出while循环:
parent = bone.parent
while(parent):
if not parent.name in bones:
bones.insert(bones.index(bone.name),parent.name)
parent = parent.parent
bone = parent
try:
bone = pbones.pop(0)
except IndexError:
break
return bones
下一步是定义脚本本身。首先,我们获取当前场景中的活动对象,并验证它是否确实是一个臂架。如果不是,我们通过弹出窗口(以下代码的突出显示部分)提醒用户,否则我们继续进行,并使用getData()方法获取相关的臂架数据:
scn = Blender.Scene.GetCurrent()
arm = scn.objects.active
if arm.getType()!='Armature':
Blender.Draw.PupMenu("Selected object is not an Armature%t|Ok")
else:
adata = arm.getData()
然后,我们将臂架设置为可编辑,并确保每个骨骼都设置了HINGE选项(突出显示)。一旦我们添加了HINGE选项,将选项列表转换为集合然后再转换回列表的业务,是一种确保选项在列表中只出现一次的方法。
adata.makeEditable()
for ebone in adata.bones.values():
ebone.options =list(set(ebone.options)|set([Blender.Armature.HINGE]))
adata.update()
一个姿态与臂架对象相关联,而不是与它的数据相关联,因此我们通过使用getPose()方法从arm获取它。骨骼姿态非常类似于普通的 IPO,但它们必须与一个动作相关联,该动作将这些姿态分组。当与 Blender 进行交互式工作时,一旦我们在姿态上插入关键帧,就会自动创建一个动作,但在脚本中,如果尚未存在,我们必须显式创建一个动作(如下突出显示):
pose = arm.getPose()
action = arm.getAction()
if not action:
action = Blender.Armature.NLA.NewAction()
action.setActive(arm)
下一步是使用我们之前定义的函数按父级链对Pose骨骼进行排序。剩下的就是以每步十帧的步长前进,并在每一步设置每个骨骼的缩放关键帧,如果骨骼的序列号与我们的步数匹配,就放大,如果不匹配,就重置。其中一个结果 IPO 在屏幕截图中显示。请注意,通过我们之前在每个骨骼上设置HINGE属性,我们防止了缩放传播到骨骼的子代:
bones = sort_by_parent(pose.bones.values())
for frame in range(1,161,10):
index = int(frame/21)-1
n = len(bones)
for i,bone in enumerate(bones):
if i == index :
size = 1.3
else :
size = 1.0
pose.bones[bone].size=Vector(size,size,size)
pose.bones[bone].insertKey(arm,frame,Blender.Object.Pose.SIZE)
完整代码作为peristaltic.py在peristaltic.blend中可用。

将peristaltic.py应用于臂架
要使用此脚本,您必须选择臂架对象后运行它。展示其应用的配方可能如下:
-
将一个臂架添加到场景中。
-
进入编辑模式,并从第一根骨骼的尖端挤出任意数量的骨骼。
-
进入对象模式,并在臂架位置添加一个网格。任何网格都行,但为了我们的演示,我们使用了一个具有足够细分的长方体。
-
选择网格,然后按住 Shift 选择骨架。现在骨架和
网格对象都被选中,而骨架是活动对象。 -
按下 Ctrl + P 并选择 骨架。在下一个弹出窗口中,选择 从骨骼热量创建。这将为骨架中的每个骨骼在网格上创建一个顶点组。这些顶点组将用于当我们将骨架作为修改器与网格关联时变形网格。
-
选择网格并添加一个骨架修改器。在Ob:字段中输入骨架的名称,并确保Vert.Group切换被选中,而Envelopes没有被选中。
-
选择骨架并运行
peristaltic.py。
结果将是一个动画的网格对象,类似于一个团块通过一个狭窄的柔性管道。图示中展示了几个帧:

如下所示,雨水管当然不是唯一适合以这种方式动画的空心物体:

跟随节奏——同步形状键与声音
许多摇滚音乐视频今天都展示了扬声器振膜随着音乐声音共振的动画。尽管 Blender API 中用于操作 声音 的功能相当稀少,但我们将看到这个效果相当简单就能实现。
我们将要构建的动画主要依赖于对 形状键 的操作。形状键可以理解为基网格的扭曲。一个网格可以有多个这样的扭曲,每个扭曲都有一个独特的名称。有趣的是,Blender 为我们提供了连续地在基形状和任何扭曲形状之间进行插值的可能性,甚至允许我们从不同的形状中混合贡献。
例如,要动画我们的扬声器振膜,一种方法是为锥形建模一个基本的、未扭曲的形状;向这个基网格添加一个形状键;并将其扭曲以类似于被推向外推的锥形。然后我们可以根据声音的响度在这“弹出”形状和基形状之间进行混合。
在 Blender 中通过设置关键帧进行动画意味着创建 IPOs 并像我们之前看到的那样操作 IPO 曲线。实际上,形状 或 关键 IPOs 与其他类型的 IPOs 非常相似,并且以非常相似的方式进行操作。例如,对象 IPO 和形状 IPO 之间的主要区别在于形状 IPO 的个别 IPO 曲线不是通过某些预定义的数值常数(如对象的Ipo.OB_LOCX)索引,而是通过字符串,因为用户可以定义任意数量的命名形状。
此外,形状 IPO 不是通过对象访问,而是通过其底层的网格对象(或晶格或曲线,因为这些也可能有形状键)。
操作声音文件
既然我们已经知道了如何动画化形状,我们的下一个目标就是找出如何给我们的网格添加一些声音,或者说确定在每一帧中扭曲的形状应该有多明显。
如前文所述,Blender 的 API 没有提供很多用于操作音频文件的工具,基本上Sound模块为我们提供了加载和播放音频文件的方法,但仅此而已。无法访问文件中编码的波形中的单个点。
幸运的是,标准的 Python 发行版自带了一个wave模块,它为我们提供了读取常见.wav格式文件的手段。尽管它只支持未压缩格式,但这已经足够了,因为这个格式非常常见,大多数音频工具,如Audacity,都可以转换成这个格式。使用这个模块,我们可以打开.wav文件,确定音频片段的采样率和时长,并访问单个样本。正如我们将在以下代码的解释中看到的那样,我们仍然需要将这些样本转换为我们可以用作形状键关键值的值,但繁重的工作已经为我们完成了。
代码概要:Sound.py
在掌握了如何构建 IPO 曲线和访问.wav文件的知识后,我们可能可以绘制以下代码概要:
-
确定活动对象是否已定义了合适的形状并提供选择。
-
允许用户选择
.wav文件。 -
确定文件中每秒的音频样本数量。
-
根据音频文件的时长和视频帧率计算所需的动画帧数。
-
然后,对于每个动画帧:
-
计算此帧中出现的音频样本的平均值
-
将所选 IPO 曲线的混合值设置为这个(归一化)平均值
-
完整的代码作为Sound.py在sound000.blend中提供,并如下所述:
import Blender
from Blender import Scene,Window,Draw
from Blender.Scene import Render
import struct
import wave
我们首先导入必要的模块,包括 Python 的wave模块来访问我们的.wav文件和struct模块,它提供了操作我们从.wav文件中获取的实际二进制数据的函数。
接下来,我们定义一个实用函数,在屏幕中间弹出菜单。它就像Draw模块中的常规PupMenu()函数一样,但在GetScreenSize()和SetMouseCoords()函数的帮助下将光标设置在屏幕中间的位置:
def popup(msg):
(w,h)=Window.GetScreenSize()
Window.SetMouseCoords(w/2,h/2)
return Draw.PupMenu(msg)
大部分工作将由sound2active()函数完成。它将接受两个参数——要使用的.wav文件的文件名和基于.wav文件中的信息的要动画化的形状键的名称。首先,我们尝试通过调用wave模块的open()函数(突出显示)来创建一个WaveReader对象。如果失败,我们将错误显示在一个弹出窗口中并退出:
def sound2active(filename,shapekey='Pop out'):
try:
wr = wave.open(filename,'rb')
except wave.Error,e:
return popup(str(e)+'%t|Ok')
然后我们进行一些合理性检查:我们首先检查.wav文件是否是MONO文件。如果您想使用立体声文件,请先将其转换为单声道,例如使用免费的 Audacity 软件包([audacity.sourceforge.net/](http://audacity.sourceforge.net/))。然后我们检查我们是否在处理未压缩的.wav文件,因为wave模块无法处理其他类型。(大多数.wav文件都是未压缩的,但如果需要,Audacity 也可以将它们转换为未压缩格式)并且我们验证样本是否为 16 位。如果这些检查中的任何一个失败,我们将弹出适当的错误消息:
c = wr.getnchannels()
if c!=1 : return popup('Only mono files are supported%t|Ok')
t = wr.getcomptype()
w = wr.getsampwidth()
if t!='NONE' or w!=2 :
return popup('Only 16-bit, uncompresses files are supported%t|Ok')
现在我们能够处理文件了,我们获取其帧率(每秒的音频样本数)以及总字节数(奇怪的是,通过使用wave模块中名为getnframes()的尴尬命名的函数)。然后,我们读取所有这些字节并将它们存储在变量b中。
fr= wr.getframerate()
n = wr.getnframes()
b = wr.readframes(n)
我们接下来的任务是获取当前场景的渲染上下文以检索每秒的视频帧数。我们的动画将播放的秒数由音频样本的长度决定,我们可以通过将.wav文件中的总音频帧数除以每秒音频帧数来计算这一点(在下面的代码片段中突出显示)。然后我们定义一个常量sampleratio——每视频帧的音频帧数:
scn = Scene.GetCurrent()
context = scn.getRenderingContext()
seconds = float(n)/fr
sampleratio = fr/float(context.framesPerSec())
如前所述,wave模块为我们提供了访问.wav文件和原始音频样本的多个属性,但提供了将原始样本转换为可用的整数值的函数。因此,我们需要自己完成这项工作。幸运的是,这并不像看起来那么困难。因为我们知道 16 位音频样本以“小端”格式作为 2 字节整数存在,我们可以使用 Python 的struct模块中的unpack()函数通过传递合适的格式说明符来有效地将字节列表转换为整数列表。(您可以在ccrma.stanford.edu/courses/422/projects/WaveFormat/上了解更多关于.wav文件布局的信息。)
samples = struct.unpack('<%dh'%n,b)
现在我们可以开始动画形状键了。我们从渲染上下文中获取起始帧,并通过将.wav文件中的秒数乘以视频帧率来计算结束帧。请注意,这可能会比我们从渲染上下文中得到的结束帧更长或更短。后者决定了当用户点击动画按钮时将渲染的最后一帧,但我们将无论这个值如何都会动画化活动对象的移动。
然后对于从起始帧到结束帧(不包括)的每一帧,我们通过将这些音频样本(存在于samples列表中)相加并除以每视频帧的音频样本数来计算每个视频帧中出现的音频样本的平均值(在下一个代码片段中突出显示)。
我们将设置所选形状键的值为 [0:1] 范围内的值,因此我们需要通过确定最小值和最大值来归一化计算的平均值并计算一个比例:
staframe = context.startFrame()
endframe = int(staframe + seconds*context.framesPerSec())
popout=[]
for i in range(staframe,endframe):
popout.append(sum(samples[int((i-1)*sampleratio):int(i*sampleratio)])/sampleratio)
minvalue = min(popout)
maxvalue = max(popout)
scale = 1.0/(maxvalue-minvalue)
最后,我们获取当前场景中的活动对象并获取其 Shape IPO(突出显示)。我们通过将考虑范围内的每个帧的形状键值设置为音频样本的缩放平均值来得出结论:
ob=Blender.Scene.GetCurrent().objects.active
ipo = ob.getData().getKey().getIpo()
for i,frame in enumerate(range(staframe,endframe)):
ipo[shapekey][frame]=(popout[i]-minvalue)*scale
剩余的脚本现在相当简单。它获取活动对象并尝试从中检索形状键名称列表(在下一部分中突出显示)。如果活动对象不是网格或没有关联的形状键,则可能会失败(因此有 try … except 子句),在这种情况下,我们将通过弹出窗口提醒用户:
if __name__ == "__main__":
ob=Blender.Scene.GetCurrent().objects.active
try:
shapekeys = ob.getData().getKey().getIpo().curveConsts
key = popup('Select a shape key%t|'+'|'.join(shapekeys))
if key>0:
Window.FileSelector
(lambda f:sound2active(f,shapekeys[key-1]),
"Select a .wav file",
Blender.Get('soundsdir'))
except:
popup('Not a mesh or no shapekeys defined%t|Ok')
如果我们能够检索到一个形状键列表,我们将向用户提供一个弹出菜单,以便从该列表中选择。如果用户选择其中一项,key 将为正值,我们将向用户提供一个文件选择器对话框(突出显示)。此文件选择器对话框传递一个 lambda 函数,如果用户选择一个文件,则将调用该函数,并将所选文件的名称作为参数传递。在我们的情况下,我们构建这个 lambda 函数来调用之前定义的 sound2active() 函数,并传递此文件名和所选形状键。
用户在文件选择器中选择的初始目录由 FileSelector() 函数的最后一个参数确定。我们将其设置为 Blender 的 soundsdir 参数的内容。这通常为 //(即指向用户正在工作的 .blend 文件所在目录的相对路径)但可以在用户首选项窗口(文件 路径部分)中设置为其他内容。
通过 .wav 文件动画网格:工作流程
现在我们有了 Sounds.py 脚本,我们可以按照以下方式应用它:
-
选择一个
Mesh对象。 -
向其添加一个 "基础" 形状键(按钮 窗口,编辑 上下文,形状 面板)。这将对网格的最扭曲形状相对应。
-
添加第二个形状键并给它一个有意义的名称。
-
编辑此网格以表示最扭曲的形状。
-
在 对象 模式下,通过按 Alt + P. 从文本编辑器运行
Sound.py。 -
从弹出窗口中选择之前定义的形状键名称(不是 "基础" 那个)。
-
选择要应用的
.wav文件。
结果将是一个具有所选形状键的 IPOcurve 对象,其波动将根据下一个截图所示的音拍:

摘要
在本章中,我们看到了如何将形状键与网格关联以及如何添加 IPO 来在那些形状键之间动画过渡。具体来说,我们学习了如何:
-
定义 IPOs
-
在网格上定义形状键
-
为这些形状键定义 IPOs
-
姿势骨架
-
将姿势变化分组到动作中
在下一章中,我们将学习如何创建自定义纹理和着色器。
第七章:使用 Pynodes 创建自定义着色器和纹理
有时人们说,尽管 Blender 有一个强大而通用的系统来定义材质,但它缺乏一个合适的着色器语言来定义全新的着色器,例如创建以新颖方式对光线做出反应的材质。然而,这并不完全正确。
Blender 没有编译的着色器语言,但它确实有一个强大的节点系统来组合纹理和材质,这些节点可以是 Python 脚本。这使得用户能够定义全新的纹理和材质。
在本章中,我们将学习:
-
如何编写创建简单颜色图案的 Pynodes
-
如何编写生成具有法线图案的 Pynodes
-
如何编写动画 Pynodes
-
如何编写高度和坡度相关的材质
-
如何创建对入射光角度做出反应的着色器
为了展示其部分功能,我们首先来看一个脚本,该脚本创建由三角形、矩形或六边形组成的常规颜色图案。

注意
材质、着色器和纹理是经常用作同义词的术语,尽管它们在意义上有所区别。为了我们的目的,我们尽量遵守以下定义:纹理是一个基本构建块,例如颜色或法线图案,或者简单地是一个根据表面位置返回值的函数。着色器将接受任意数量的纹理或基本颜色,并将根据入射光的影响(以及可能的视图方向)返回一个颜色。材质是一组纹理、着色器和可以应用于对象的各类属性。Pynodes 可以是纹理,也可以是着色器。
基础知识
当我们设计一个 Pynode 时,我们基本上设计的是一种为屏幕上每个需要该节点着色的像素(或者如果启用了超采样,则甚至超过一次)提供函数的东西。此函数将获得其他事物,例如对象上被着色的点的 x、y 和 z 坐标,这些坐标对应于我们当前正在计算的屏幕上的像素。然后,该函数预计将返回一些有用的东西,例如颜色、强度值,或者稍微不那么直观的东西,如法线。
在 Blender 的节点编辑器窗口中,每个材质节点,包括 Pynode,都由一个盒子表示,其输入在左侧,输出在右侧。这些输入和输出通常被称为插座,并由小彩色圆圈表示(参见下一张截图)。这些插座可以用来连接节点;通过单击一个节点的输出插座并拖动鼠标到另一个节点的输入插座,这些节点将被连接。通过组合所需的不同节点数量,可以创建非常复杂和强大的着色器。
从节点到 Pynodes
Blender 的节点系统的强大之处不仅在于其许多预定义的节点类型和这些节点可能连接的许多方式,还在于我们可以用 Python 编写新的节点,这些节点可以以与普通节点相同的方式连接。
Pynodes 需要一种方式来访问由输入插座提供的信息,以及一种方式将它们计算出的结果发送到输出插座。节点及其插座的概念是按照面向对象模型结构化的。让我们先看看一些示例代码来证明这并不需要令人害怕(面向对象的老兵:请向另一边看或用手指遮住眼睛,只从下面的示例中获取类定义):
from Blender import Node
class MyNode(Node.Scripted):
def __init__(self, sockets):
sockets.input = [Node.Socket('Coords', val= 3*[1.0])]
sockets.output = [Node.Socket('Color', val = 4*[1.0])]
def __call__(self):
x,y,z = self.input.Coords
self.output.Color = [abs(x),abs(y),abs(z),1.0]
在我们详细查看此代码之前,先在 Blender 中尝试它,看看它实际上是如何工作的:
-
在文本编辑器中打开一个新文件,并给它一个可区分的名称。
-
复制示例代码。
-
创建一个简单的场景,例如,在原点处的一个简单的 UV 球体,配上一两个灯具和一个摄像机。
-
像平常一样,将
Node材质分配给球体。 -
最后,在节点编辑器中添加一个 动态 节点(添加 | 动态),通过点击 动态 节点的选择按钮并选择你编辑的文件来选择文件的名称。
结果的节点网络(通常称为 面条)可能看起来像这样:

如果你渲染球体,结果是一个五彩斑斓的球体,与颜色选择小部件不相上下。
现在回到代码。
在第一行中,我们从 Blender 导入 Node 模块,因为我们将实现一种新的节点类型,但其中大部分行为已经在 Node 模块中定义。
然后我们定义了一个名为 MyNode 的类,它是 Node.Scripted 的子类,它将表现得就像一个 Scripted 节点,除了我们将要重新定义的部分。
接下来,我们定义了 __init__() 函数,它将在我们在节点编辑器中第一次创建此类 Pynode 或点击 更新 按钮时被调用。当发生这种情况时,Blender 将向此函数传递两个参数:self,指向我们使用的节点的指针,以及 sockets,一个指向我们的输入和输出插座列表的对象的引用。这些是我们将在节点编辑器中接收输入或发送数据的节点。
在高亮行中,我们定义了一个输入插座定义列表;在这种情况下只有一个,称为 Coords。它是一个向量输入,因为它使用一个包含三个浮点数的列表初始化默认值,如果这个输入插座没有连接到另一个节点。在节点编辑器中,向量节点以蓝色圆圈表示。
也可能存在其他类型的输入插座,其类型由val参数的值确定。输出插座以相同的方式定义。一个包含三个浮点数的列表将定义一个向量插座,一个包含四个浮点数的列表将定义一个颜色插座(具有红色、绿色、蓝色和 alpha 组件),而表示简单值(如强度)的插座由单个浮点数初始化。请注意,我们无法区分需要用户填写或应连接到另一个节点的输入。我们使用输入插座来完成这两项任务,并将不得不记录它们的预期用途。目前,没有提供添加按钮或其他小部件到 Pynode 的功能。
我们的示例 Pynode 也需要输出,因此我们定义了一个包含单个输出插座(称为Color)的列表。它有四个默认的浮点值,分别指定红色、绿色、蓝色和 alpha 值。
接下来,我们定义了一个函数__call__(),每次对像素进行着色时都会调用它。它不接受任何参数,只有self——当前节点的引用,在以下行中用于访问输入和输出插座。
在__call__()的主体中,我们从名为Coords的输入插座检索三个组件,并将它们分配给易于记忆的变量。最后,我们创建了一个新的四分量列表,代表我们计算出的颜色,并将其分配给名为Color的输出插座。
这是定义简单纹理的基础,但节点(正如我们将在以下章节中看到的)还有更多信息可用,因此可以设计一些相当复杂的效果。我们以一个稍微更复杂的节点结束本节,该节点基于我们之前看到的相同原则,但创建了更有用的图案。
正规镶嵌
棋盘纹理可能是你可以想象的最简单的纹理,因此在编程纹理时经常用作示例。因为 Blender 已经内置了棋盘纹理(自 2.49 版本以来,在节点窗口的纹理上下文中),所以我们更进一步,创建了一个不仅显示棋盘纹理,还显示三角形和六边形镶嵌的纹理节点。
from Blender import Node,Noise,Scene
from math import sqrt,sin,cos,pi,exp,floor
from Blender.Mathutils import Vector as vec
# create regular tilings to be used as a color map
class Tilings(Node.Scripted):
def __init__(self, sockets):
sockets.input = [Node.Socket('type' , val= 2.0, min = 1.0, max = 3.0),
Node.Socket('scale' , val= 2.0, min = 0.1, max = 10.0),
Node.Socket('color1', val= [1.0,0.0,0.0,1.0]),
Node.Socket('color2', val= [0.0,1.0,0.0,1.0]),
Node.Socket('color3', val= [0.0,0.0,1.0,1.0]),
Node.Socket('Coords', val= 3*[1.0])]
sockets.output = [Node.Socket('Color', val = 4*[1.0])]
前几行首先定义了我们的输入和输出插座。在所有情况下输出将简单地是一个颜色,但我们有一组更丰富的输入插座。我们定义了三种不同的输入颜色,因为六边形图案需要三种颜色来给每个六边形一个与其相邻六边形可区分的颜色。
我们还定义了一个Coords输入插座。这个输入插座可以连接到任何几何插座的输出。这样,我们就有了将我们的颜色纹理映射到纹理对象的各种可能性。还定义了一个Scale插座来控制纹理的大小。
最后,我们定义一个 Type 插槽来选择我们希望生成的图案。由于 Pynode API 不提供下拉框或其他简单的选择小部件,我们使用值插槽并任意选择值来表示我们的选择:1.0 代表三角形,2.0 代表棋盘,3.0 代表六边形。
我们在 __init__() 函数的末尾定义了一系列常量和颜色映射的字典,我们将在生成六角纹理时使用这些。
self.cos45 = cos(pi/4)
self.sin45 = sin(pi/4)
self.stretch = 1/sqrt(3.0)
self.cmap = { (0,0):None,(0,1):2, (0,2):0,(1,0):0, (1,1):1, (1,2):None,(2,0):2, (2,1):None,(2,2):1 }
下一步是定义 __call__() 函数:
def __call__(self):
tex_coord = self.input.Coords
# we disregard any z coordinate
x = tex_coord[0]*self.input.scale
y = tex_coord[1]*self.input.scale
c1 = self.input.color1
c2 = self.input.color2
c3 = self.input.color3
col= c1
__call__() 函数首先定义了一些输入值的缩写,并将输入坐标乘以所选的缩放比例以拉伸或缩小生成的图案。下一步是确定所需的图案类型,并调用适当的函数来计算给定坐标的输出颜色。结果颜色被分配给我们的唯一输出插槽:
if self.input.type<= 1.0:
col = self.triangle(x,y,c1,c2)
elif self.input.type <= 2.0:
col = self.checker(x,y,c1,c2)
else:
col = self.hexagon(x,y,c1,c2,c3)
self.output.Color = col
各种图案生成函数都非常相似;它们接受 x 和 y 坐标以及两种或三种颜色作为参数,并返回一种颜色。作为类的成员函数,它们还接受一个额外的 self 参数。
def checker(self,x,y,c1,c2):
if int(floor(x%2)) ^ int(floor(y%2)):
return c1
return c2
checker 函数检查我们在哪一行和哪一列,如果行号和列号都是奇数或偶数(这就是排他性“或”操作符所建立的),它返回一种颜色;如果不是,它返回另一种颜色。
def triangle(self,x,y,c1,c2):
y *= self.stretch
x,y = self.cos45*x - self.sin45*y, self.sin45*x + self.cos45*y
if int(floor(x%2)) ^ int(floor(y%2)) ^ int(y%2>x%2) : return c1
return c2
triangle 函数首先将 x 和 y 坐标一起旋转 45 度角(将正方形变为直立的长方形)。然后根据行和列的数字确定颜色,就像在 checker 函数中一样,但有一个转折:第三个条件(突出显示)检查我们是否位于对角线交叉的正方形左侧,因为我们已经旋转了我们的网格,所以我们实际上检查坐标是否在分割长方形的水平线上方。这可能听起来有点复杂,但你可以查看以下屏幕截图来获得概念:

def hexagon(self,x,y,c1,c2,c3):
y *= self.stretch
x,y = self.cos45*x - self.sin45*y, self.sin45*x + self.cos45*y
xf = int(floor(x%3))
yf = int(floor(y%3))
top = int((y%1)>(x%1))
c = self.cmap[(xf,yf)]
if c == None:
if top :
c = self.cmap[(xf,(yf+1)%3)]
else :
c = self.cmap[(xf,(yf+2)%3)]
return (c1,c2,c3)[c]
在许多方面,hexagon 函数类似于 triangle 函数(毕竟六边形是由六个三角形粘合而成的)。因此,它执行相同的旋转技巧,但不是通过简单的公式来选择颜色,事情变得更为复杂,所以我们在这里使用颜色图(在之前的代码片段中突出显示)。基本上,我们将屏幕分为水平和垂直的条带,并根据我们所在的条带来选择颜色。
最后的魔法在于我们脚本的最后一行:
__node__ = Tilings
目前 Pynodes 的实现方式下,Blender 需要这个赋值来识别一个类为节点。我们的节点将出现在脚本节点的弹出菜单中,标记为 Tilings。完整的代码作为 tilings.py 包含在 tilings.blend 文件中,并附有示例节点设置。一些可能的图案在下一张屏幕截图中展示:

对应的节点设置在下一张截图中显示。请注意,我们没有将任何节点连接到颜色输入,但如果这样做,可以创建更复杂的图案。

抗锯齿
如果你仔细观察六边形或三角形镶嵌的对角边界,即使过采样设置为高值,你也会注意到一些类似楼梯的伪影。
Blender 本身足够智能,可以将选择的抗锯齿级别应用于诸如对象边界之类的事物,但在大多数情况下,表面上的纹理将必须自行处理抗锯齿。Blender 的内置纹理当然是这样设计的,但用 Pynodes 生成的我们自己的纹理应该明确处理这一点。
有许多数学技术可用于减少生成纹理中的混叠,但大多数都不容易实现或需要特定于图案生成方式的知识。幸运的是,Blender 为我们提供了全 OSA选项(按钮窗口 | 着色上下文 | 材质按钮 | 链接和管道选项卡)。如果我们启用此选项,Blender 将被迫通过渲染按钮中选择的量对纹理中的每个像素进行过采样。这是一个昂贵的选项,但可以消除混叠效果,而无需在我们的 Pynode 纹理中实现特定的过滤选项。
通过向量索引纹理
在我们的镶嵌图案中,我们将颜色限制为区分每个相邻瓦片所需的最小数量。但根据某些噪声纹理随机分配颜色是否可能?这样我们可能会以遵循整体随机模式的方式着色鱼鳞,同时使每个单独的鳞片均匀着色。
我们不能简单地将彩色纹理连接到颜色输入,因为这可能会产生有趣的图案,但每个瓦片可能不会有均匀的颜色。解决方案是修改我们的 Pynode 以生成一个在给定瓦片内均匀的唯一向量。这个向量可以连接到任何以向量为输入的噪声纹理,因为所有 Blender 纹理都可以这样做。这个向量由噪声纹理节点用来指向随机纹理中的单个点,这样我们就可以产生随机着色但均匀的瓦片。
为了提供这种功能,我们通过删除颜色输入并替换颜色输出为向量输出(未显示)来修改我们的代码。现在__call__()函数内部的代码将必须生成一个向量而不是颜色。这里我们展示了修改后的triangle函数(完整代码作为tilingsv.py在tilingsv.blend中提供):
def triangle(self,x,y):
y *= self.stretch
x,y = self.cos45*x - self.sin45*y, self.sin45*x + self.cos45*y
if int(floor(x%2)) ^ int(floor(y%2)) ^ int(y%2>x%2) :
return [floor(x),floor(y),0.0]
return [floor(x)+0.5,floor(y),0.0]
逻辑基本上相同,但如图中高亮行所示,我们返回一个依赖于位置的向量。然而,由于floor()操作,它在三角形内是恒定的。请注意,对于交替的三角形,我们添加了一个轻微的偏移;只要它是恒定的并且产生与其他三角形不同的向量,我们选择的偏移量无关紧要。
结果显示了一个随机的三角形模式,它遵循噪声中的大相关性,但每个三角形都保持统一的颜色。右侧的样本使用了更大的噪声尺寸的云纹理:
.jpg)
下面的截图显示了可能的节点设置:
.jpg)
新鲜微风——带有法线的纹理
一个纹理可以不仅仅有几何输入。如果你需要一个纹理根据另一个纹理改变其行为,而这种行为不能通过简单的节点设置实现,你可以为它提供额外的输入插座。我们将开发一个 Pynode,它生成一个模拟几乎无风的一天池塘上小波的碎片法线图。
那些补丁出现的位置由一个额外的输入插座控制,它可以连接到几乎任何噪声纹理。我们将把这个输入插座命名为amplitude,因为我们用它来乘以我们计算出的法线。这样,我们的小波将在噪声纹理为零的地方消失。
波纹的波长由另一个名为wavelength的输入控制,我们的Ripples节点还将有一个用于坐标的输入插座。
第四个和最后一个输入称为direction——一个控制我们小波方向的向量。用户可以手动设置它,但如果需要,也可以连接到一个提供通过鼠标轻松操纵方向的简单方法的法线节点。
所有的这些组合在一起的节点设置在节点编辑器的截图中显示:

节点的脚本很简单;在导入一些必要的定义之后,我们定义了众多的输入插座和我们的单个输出插座:
from Blender import Node
from math import cos
from Blender.Mathutils import Vector as vec
class Ripples(Node.Scripted):
def __init__(self, sockets):
sockets.input = [Node.Socket('amplitude' , val= 1.0, min = 0.001, max = 1.0),
Node.Socket('wavelength', val= 1.0, min = 0.01, max = 1000.0),
Node.Socket('direction' , val= [1.0,0.0,0.0]),
Node.Socket('Coords' , val= 3*[1.0])]
sockets.output = [Node.Socket('Normal', val = [0.0,0.0,1.0])]
def __call__(self):
norm = vec(0.0,0.0,1.0)
p = vec(self.input.Coords)
d = vec(self.input.direction)
x = p.dot(d)*self.input.wavelength
norm.x=-self.input.amplitude*cos(x)
n = norm.normalize()
self.output.Normal = n*.01
__node__ = Ripples
再次强调,所有真正的操作都是在__call__()函数中完成的(在前面的代码片段中突出显示)。我们首先为坐标和方向向量分别定义了缩写p和d。我们的小波是正弦函数,而这个正弦曲线上的位置是由位置在方向向量上的投影确定的。这个投影是通过计算“内积”或“点积”——这是由Vector对象的dot()方法提供的一个操作来计算的。

然后将这个投影乘以波长。如果我们计算正弦波,我们会得到我们波的高度。然而,我们感兴趣的并不是高度,而是法线。法线始终向上指,并随着我们的正弦波移动(参见下一张图)。可以证明这个法线是一个具有 1.0 的 z 分量和与正弦函数的负导数相等的 x 分量的向量,即负余弦。脚本(ripples.py)和示例节点设置作为ripples.blend提供。

在我们之前展示的节点设置中,你可能已经注意到,我们没有直接将几何节点链接到我们的涟漪节点,而是添加了一个第二个纹理节点,并通过添加和缩放纹理节点的法线输出与几何输入相结合。我们本来可以在涟漪节点中混合一些噪声,但这样我们可以给用户更多的控制权,让他能够添加(如果需要)类型和数量的噪声。这是一个通用模式:节点应该尽可能简单,以便在不同的设置中重复使用。
这些涟漪并不是为了动画设计的,但在下一节中,我们将设计一个可以动画化的节点。

雨滴——动画 Pynodes
许多模式不是静态的,而是随时间变化。一个例子是雨滴落在池塘中形成的涟漪。Blender 提供了渲染时间参数,如起始帧、帧率和当前帧,这样我们就有很多钩子来使我们的 Pynodes 具有时间依赖性。我们将看到如何在生成雨滴图案的脚本中使用这些钩子。这种模式会以逼真的方式改变,类似于水滴落在池塘中产生的向外扩展的涟漪。在这个过程中,我们也会学到一些有用的技巧,通过在 Pynode 中存储昂贵计算的结果来加速计算。
渲染时间参数
处理与时间相关的事物时,最相关的渲染参数是当前帧数和帧率(每秒的帧数)。这些参数由Scene模块以渲染上下文的形式提供,大多数通过函数调用,一些作为变量:
scn = Scene.GetCurrent()
context = scn.getRenderingContext()
current_frame = context.currentFrame()
start_frame = context.startFrame()
end_frame = context.endFrame()
frames_per_second = context.fps
有了这个信息,我们现在可以计算时间,无论是绝对时间还是相对于起始帧的时间:
absolute_time = current_frame/float(frames_per_second)
relative_time = (current_frame-start_frame)/float(frames_per_second)
注意分母中的浮点数转换(突出显示)。这样我们确保除法被视为浮点运算。这并不是绝对必要的,因为fps作为浮点数返回,但许多人假设帧率是一个整数值,如 25 或 30。然而,这并不总是情况(例如,NTSC 编码使用分数帧率),所以我们最好明确这一点。此外,请注意,我们不能去掉这个除法,否则当人们改变他们选择的帧率时,动画的速度会改变。
看起来好的就是好的
准确模拟由落水滴引起的涟漪的外观可能看起来很困难,但却是直截了当的,尽管有些复杂。对底层数学感兴趣的读者可能想查看一些参考资料(例如en.wikipedia.org/wiki/Wave)。然而,我们的目标并不是尽可能准确地模拟现实世界,而是为艺术家提供一个看起来好且可控的纹理,这样纹理甚至可以应用于不现实的情况。
因此,我们不是让涟漪传播的速度依赖于诸如水的粘度等因素,而是将速度作为一个可调输入提供给我们的 Pynode。同样,对于涟漪的高度和宽度以及涟漪高度随其扩展而减小的速率也是如此。基本上,我们通过一个余弦函数乘以一个指数函数和一个阻尼因子来近似我们的涟漪小包,它从水滴的撞击点向外辐射。这听起来可能又像是数学,但它可以很容易地被可视化:

要计算纹理中任何位置 x, y 的高度,可以按照以下方式实现:
position_of_maximum=speed*time
damping = 1.0/(1.0+dampf*position_of_maximum)
distance = sqrt((x-dropx)**2+(y-dropy)**2)
height = damping*a*exp(-(distance-position_of_maximum)**2/c)* \cos(freq*(distance-position_of_maximum))
在这里,dropx 和 dropy 是水滴的撞击位置,而 a 是我们可调的高度参数。
在不同时间、不同位置滴下更多水滴的效果,可以通过简单地将结果高度相加来计算。
存储昂贵的计算结果以供重用
单个水滴当然不是雨,因此我们希望看到许多随机水滴叠加的效果。因此,我们必须为想要模拟的水滴选择随机的撞击位置和时间。
我们必须在每次调用 __call__() 方法时(即,在我们的纹理中的每个可见像素)都这样做。然而,这将是一种巨大的处理能力浪费,因为计算许多随机数以及为可能的大量水滴分配和释放内存是昂贵的。
幸运的是,我们可以将这些结果存储为 Pynode 的实例变量。当然,我们应该小心检查在 __call__() 调用之间是否有任何输入参数发生了变化,如果发生了变化,则采取适当的行动。一般模式如下:
class MyNode(Node.Scripted):
def __init__(self, sockets):
sockets.input = [Node.Socket('InputParam', val = 1.0)]
sockets.output = [Node.Socket('OutputVal' , val = 1.0)]
self.InputParam = None
self.Result = None
def __call__(self):
if self.InputParam == None or \
self.InputParam != self.input.InputParam :
self.InputParam = self.input.InputParam
self.Result = expensive_calculation ...
self.output.OutputVal = other_calculations_using_Result …
此模式仅在输入参数不经常变化的情况下有效,例如,只有当用户更改它时。如果输入每像素都发生变化,因为输入插口连接到另一个节点的输出——建议的方案只会浪费时间而不是节省时间。
计算法线
我们的目的是生成一个可以作为法线的涟漪图案,因此我们需要一种方法从计算的高度中导出法线。Blender 没有为我们提供用于材质的此类转换节点,因此我们必须自己设计方案。
注意
与材质节点不同,Blender 的纹理节点确实提供了一个名为“值到法线”的转换函数,该函数在纹理节点编辑器中可用,从菜单 添加|转换器|值 到法线。
现在,正如在涟漪的情况下,原则上我们可以计算雨滴的确切法线,但为了避免再次走数学路线,我们采用许多内置噪声纹理中使用的计算法线的方法,这种方法不依赖于底层函数。
只要我们能在三个点上评估一个函数:f(x,y),f(x+nabla,y) 和 f(x,y+nabla),我们就可以通过观察函数在 x 和 y 方向上的斜率来估计 x,y 处的法线方向。表面法线将是垂直于由这两个斜率定义的平面的单位向量。我们可以从任何小的 nabla 值开始,如果看起来不好,我们可以将其减小。
将所有这些放在一起
从前面的段落中汲取所有这些想法,我们可以为我们的雨滴 Pynode 编写以下代码(省略了 import 语句):
class Raindrops(Node.Scripted):
def __init__(self, sockets):
sockets.input = [Node.Socket('Drops_per_second' , val = 5.0,min = 0.01, max = 100.0),
Node.Socket('a',val=5.0,min=0.01,max=100.0),
Node.Socket('c',val=0.04,min=0.001,max=10.0),
Node.Socket('speed',val=1.0,min=0.001,max=10.0),
Node.Socket('freq',val=25.0,min=0.1,max=100.0),
Node.Socket('dampf',val=1.0,min=0.01,max=100.0),
Node.Socket('Coords', val = 3*[1.0])]
sockets.output = [Node.Socket('Height', val = 1.0),
Node.Socket('Normal', val = 3 *[0.0])]
self.drops_per_second = None
self.ndrops = None
初始化代码定义了除坐标之外的一些输入插座。Drops_per_second 应该是显而易见的。a 和 c 是从撞击点向外传播的波纹的整体高度和宽度。speed 和 freq 决定了我们的波纹传播速度以及波纹之间的距离。波纹向外传播时高度减小的速度由 dampf 确定。
我们还定义了两个输出插座:Height 将包含计算出的高度,而 Normal 将包含在同一位置的相应法线。Normal 是你通常用来获得波纹表面效果的东西,但计算出的高度可能对例如衰减表面的反射率值很有用。
初始化以定义一些实例变量结束,这些变量将用于确定我们是否需要再次计算水滴撞击的位置,正如我们在 __call__() 函数的定义中将会看到的。
__call__() 函数的定义从初始化多个局部变量开始。一个值得注意的点是,我们设置了 Noise 模块函数使用的随机种子(以下代码中突出显示)。这样,我们确保每次重新计算撞击点时都会得到可重复的结果,也就是说,如果我们首先将每秒水滴数设置为十,然后设置为二十,最后再回到十,生成的图案将是相同的。如果你想要更改这一点,你可以添加一个额外的输入插座,用作 setRandomSeed() 函数的输入:
def __call__(self):
twopi = 2*pi
col = [0,0,0,1]
nor = [0,0,1]
tex_coord = self.input.Coords
x = tex_coord[0]
y = tex_coord[1]
a = self.input.a
c = self.input.c
Noise.setRandomSeed(42)
scn = Scene.GetCurrent()
context = scn.getRenderingContext()
current_frame = context.currentFrame()
start_frame = context.startFrame()
end_frame = context.endFrame()
frames_per_second = context.fps
time = current_frame/float(frames_per_second)
下一步是确定我们是否需要重新计算水滴撞击点的位置。这只有在用户更改输入插座 Drops_per_second 的值时才是必要的(你可以将这个输入连接到一些其他节点,该节点在每个像素处更改此值,但这不是一个好主意)或者当动画的开始或结束帧发生变化时,因为这会影响我们需要计算的水滴数量。这个测试通过比较新获得的值与存储在实例变量中的值来在以下代码的突出行中执行:
drops_per_second = self.input.Drops_per_second
# calculate the number of drops to generate
# in the animated timeframe
ndrops = 1 + int(drops_per_second * (float(end_frame) –
start_frame+1)/frames_per_second )
if self.drops_per_second != drops_per_second
or self.ndrops != ndrops:
self.drop = [ (Noise.random(), Noise.random(),
Noise.random() + 0.5) for i in range(ndrops)]
self.drops_per_second = drops_per_second
self.ndrops = ndrops
如果我们必须重新计算水滴的位置,我们将一个由元组组成的列表分配给self.drop实例变量,每个元组包含水滴的 x 和 y 位置以及一个随机的水滴大小,这将衰减波纹的高度。
其余的行每次调用__call__()时都会执行,但突出显示的行确实显示了显著的优化。因为当前帧中尚未落下的水滴不会对高度产生影响,所以我们排除了这些水滴的计算:
speed=self.input.speed
freq=self.input.freq
dampf=self.input.dampf
height = 0.0
height_dx = 0.0
height_dy = 0.0
nabla = 0.01
for i in range(1+int(drops_per_second*time)):
dropx,dropy,dropsize = self.drop[i]
position_of_maximum=speed*time-i/float(drops_per_second)
damping = 1.0/(1.0+dampf*position_of_maximum)
distance = sqrt((x-dropx)**2+(y-dropy)**2)
height += damping*a*dropsize*
exp(-(distance-position_of_maximum)**2/c)*
cos(freq*(distance-position_of_maximum))
distance_dx = sqrt((x+nabla-dropx)**2+(y-dropy)**2)
height_dx += damping*a*dropsize*
exp(-(distance_dx-position_of_maximum)**2/c)*
cos(freq*(distance_dx-position_of_maximum))
distance_dy = sqrt((x-dropx)**2+(y+nabla-dropy)**2)
height_dy += damping*a*dropsize*
exp(-(distance_dy-position_of_maximum)**2/c)*
cos(freq*(distance_dy-position_of_maximum))
在前面的代码中,我们实际上在三个不同的位置计算高度,以便能够近似法线(如前所述)。这些值在下面的行中用于确定法线的 x 和 y 分量(z 分量设置为 1)。计算出的高度本身被除以滴落次数(因此当滴落次数改变时,平均高度不会改变)以及整体缩放因子a,用户可以在将其分配给输出端口之前设置它(突出显示):
nor[0]=height-height_dx
nor[1]=height-height_dy
height /= ndrops * a
self.output.Height = height
N = (vec(self.shi.surfaceNormal)+0.2*vec(nor)).normalize()
self.output.Normal= N
__node__ = Raindrops
然后将计算出的法线添加到我们在计算像素处的表面法线,这样波纹在曲面上看起来仍然很好,并且在分配到输出端口之前进行归一化。通常,最后一行定义了这个 Pynode 的有意义名称。完整的代码和示例节点设置作为raindrops.py在raindrops.blend中可用。下一个屏幕截图显示了动画的一个样本帧:

下面的屏幕截图显示了示例节点设置:

威瑟林高地——一个坡度相关的材质
在 Blender 中生成分形地形相当简单(只需添加一个平面,进入编辑模式,选择所有,然后多次细分分形 W → 3)。如果你想要更复杂的东西,有一些优秀的脚本可以帮助你(例如,参见sites.google.com/site/androcto/Home/python-scripts/ANTLandscape_104b_249.py)。但是,你将如何将这些纹理应用到这样的地形上呢?在这个例子中,我们将研究一种根据我们要着色的表面的坡度来选择不同材质输入的方法。这将使我们能够以相当令人信服的方式着色山岳地形。
注意
减少计算时间:
Pynodes 计算密集,因为它们对每个可见像素都会被调用。巧妙的编码有时可以减少所需的计算量,但如果需要进一步加速,即时编译器可能会有帮助。psyco就是这样一种编译器,我们将在最后一章中遇到它,我们将将其应用于 Pynodes 并看看它是否有任何可感知的影响。
确定斜率
斜率可以定义为地板平面与在感兴趣点处与表面相切的线之间的角度。

因为我们假设我们的(想象中的)地板平面沿着 x 和 y 轴水平延伸,所以这个角度完全由同一点的表面法线的 z 分量决定。现在我们可以精确地计算出这个角度(它就是
),但作为艺术家,我们可能仍然希望有一些额外的控制,所以我们简单地取表面法线的归一化 z 分量,并用我们喜欢的任何颜色渐变节点修改这个输出强度。在一个 Pynode 中,表面法线是一个现成的矢量实体:self.input.shi.surfaceNormal。然而,有一个问题...
世界空间与相机空间
我们可用的表面法线恰好是在相机空间中定义的。这意味着,例如,当表面法线直接指向相机时,它被定义为(0,0,-1)。现在我们希望我们的表面法线在世界空间中定义。例如,一个直指上方的法线应该有一个值为(0,0,1),无论相机的位置或倾斜角度如何(毕竟,山腰的植被通常不会随着相机角度的变化而变化)。幸运的是,我们可以通过取相机的世界空间矩阵并将其与矩阵的旋转部分相乘来从相机空间转换为世界空间。生成的代码看起来像这样:
class Slope(Node.Scripted):
def __init__(self, sockets):
sockets.output = [Node.Socket('SlopeX', val = 1.0),Node.Socket('SlopeY', val = 1.0),Node.Socket('SlopeZ', val = 1.0),]
self.offset = vec([1,1,1])
self.scale = 0.5
注意,初始化代码没有定义输入插座。我们将从着色器输入(在下一部分代码中突出显示)获取我们着色像素位置的表面法线。我们确实定义了三个单独的输出插座,用于斜率的 x、y 和 z 分量,以便在节点设置中易于使用。因为我们主要将使用斜率的 z 分量,所以将其放在单独的插座中可以节省我们使用额外的矢量操作节点从矢量中提取它的麻烦。
def __call__(self):
scn=Scene.GetCurrent()
cam=scn.objects.camera
rot=cam.getMatrix('worldspace').rotationPart().resize4x4();
N = vec(self.shi.surfaceNormal).normalize().resize4D() * rot
N = (N + self.offset ) * self.scale
self.output.SlopeX=N[0]
self.output.SlopeY=N[1]
self.output.SlopeZ=N[2]
__node__ = Slope
从相机空间到世界空间的转换是在引用表面法线的行中完成的(突出显示)。方向仅取决于旋转,因此我们在将表面法线与之相乘之前,只提取相机的变换矩阵的旋转部分。由于归一化结果可能指向下方,我们通过加 1 并乘以 0.5 来强制 z 分量位于[0, 1]的范围内。完整的代码作为slope.py在slope.blend中可用。
有一个重要的事情需要注意:我们这里使用的表面法线没有插值,因此在一个单一面的表面上处处相等,即使面的smooth属性被设置。在一个细分得很细的景观中,这不应该是一个问题,因为斜率输入没有直接使用。然而,这与你预期的不同。在当前 Pynodes 的实现中,这个限制是很难克服的,如果不是不可能的话。
下面的插图展示了一个可能的例子。

上面的效果是通过在下一张截图所示的节点设置中组合不同的材料实现的。这个设置在slope.blend中也是可用的。下面两种材料是用我们的斜率相关节点混合的,然后根据一个计算高度的 Pynode 将得到的材料与上层材料混合。

肥皂泡——视图相关的着色器
一些材料会根据我们观察的角度改变它们的外观。鸟的羽毛、一些豪华汽车的油漆、水上的油污,以及肥皂泡就是一些例子。这种改变颜色的现象被称为彩虹色。如果我们想实现类似的效果,我们需要访问视图向量和表面法线。在我们的肥皂泡着色器中,我们看到实现这一点的其中一种方法。
首先是一些数学:为什么肥皂泡会显示出所有那些不同的颜色?肥皂泡基本上是带有少量肥皂的弯曲水片,在空气和水之间的界面处,光会被反射。因此,入射光线在击中气泡的外表面时会被部分反射,当它达到内表面时又会再次反射。因此,达到眼睛的反射光是一种混合了走过不同距离的光;其中一部分走过肥皂泡厚度的两倍额外距离。
现在,光的行为就像波一样,相互干涉的波可以根据它们的相位相互减弱或增强。两条光线如果它们走过的距离之差不是它们波长的整数倍,就会相互抵消。结果是,白光(一系列颜色的连续体)从厚度等于某些特定颜色波长一半的肥皂泡上反射回来时,只会显示出那种单一的颜色,因为所有其他颜色都被减弱了,因为它们在内外表面之间“不合适”。(关于肥皂泡还有很多更多。更多和更准确的信息请参考:www.exploratorium.edu/ronh/bubbles/bubble_colors.html。)

现在我们知道,两个反射表面之间的传播距离决定了我们感知到的颜色,我们也可以理解为什么肥皂泡中会有颜色变化。第一个因素是气泡的曲率。传播的距离将取决于入射光和表面之间的角度:这个角度越浅,光线在表面之间传播的距离就越长。由于表面曲率的变化,入射角也会变化,因此距离和颜色也会变化。颜色变化的第二个来源是表面的不均匀性;由于重力或气流或温度差异引起的轻微变化也会导致不同的颜色。
所有这些信息都转化为一段令人惊讶的简短代码(完整代码作为 irridescence.py 在 irridescence.blend 中提供,并附有示例节点设置)。
除了坐标外,我们有两个输入插槽——一个用于水膜的厚度,一个用于变化。变化将添加到厚度上,可以连接到纹理节点以生成漩涡等。我们有一个用于计算距离的单个输出插槽:
class Iridescence(Node.Scripted):
def __init__(self, sockets):
sockets.input = [ Node.Socket('Coords', val= 3*[1.0]),Node.Socket('Thickness', val=275.0,min=100.0, max=1000.0),Node.Socket('Variation', val=0.5, min=0.0,max=1.0)]
sockets.output = [Node.Socket('Distance', val=0.5, min=0.0,max=1.0)]
反射颜色的计算从获取场景中所有灯具的列表开始,因为我们希望计算入射光线的角度。目前,我们只考虑我们找到的第一个灯具的贡献。然而,一个更完整的实现将考虑所有灯具,甚至它们的颜色。在我们的计算中,我们必须确保表面法线 N 和光线的入射向量 L 在同一空间中。由于提供的表面法线将在相机空间中,我们将不得不通过相机的变换矩阵来转换这个向量,就像我们在斜率相关的着色器中做的那样(以下代码片段中突出显示):
def __call__(self):
P = vec(self.input.Coords)
scn=Scene.GetCurrent()
lamps = [ob for ob in scn.objects if ob.type == 'Lamp']
lamp = lamps[0]
cam=scn.objects.camera
rot=cam.getMatrix('worldspace').rotationPart().resize4x4();
N = vec(self.shi.surfaceNormal).normalize().resize4D() * rot
N = N.negate().resize3D()
L = vec(lamp.getLocation('worldspace'))
I = (P – L).normalize()
接下来,我们计算表面法线和入射向量之间的角度(VecT 是 Mathutils.angleBetweenVecs() 的别名),并使用这个入射角度来计算水膜内部表面法线之间的角度,因为这将确定光线的传播距离。我们使用 斯涅尔定律 来计算这个值,并使用 1.31 作为水膜的折射率。计算距离然后只是一个简单的三角学问题(以下突出显示):
angle = VecT(I,N)
angle_in = pi*angle/180
sin_in = sin(angle_in)
sin_out = sin_in/1.31
angle_out = asin(sin_out)
thickness = self.input.Thickness + self.input.Variation
distance = 2.0 * (thickness / cos (angle_out))
计算出的距离等于我们将感知到的颜色的波长。然而,Blender 不使用波长,而是使用 RGB 颜色,因此我们仍然需要将这个波长转换为表示相同颜色的 (R, G, B) 元组。这可能通过应用某些光谱公式来完成(例如,参见 www.philiplaven.com/p19.html),但这甚至可能更灵活,通过缩放计算出的距离并将其用作颜色带的输入。这样我们可能会产生非物理准确的彩虹效果(如果需要的话):
self.output.Distance = distance
要使用此 Pynode,有一些事情需要注意。首先,确保计算出的颜色只影响肥皂泡材料的镜面颜色,否则一切都会显得淡化。
此外,添加一些厚度变化是很重要的,因为没有任何真实的肥皂泡具有完全均匀的厚度。噪声纹理的选择可以显著影响外观。在下一个节点设置示例中,我们添加了略微噪动的木纹纹理的贡献,以获得肥皂膜上常见的螺旋带。
最后,使肥皂膜的材料非常透明但具有高镜面反射率。实验不同的值以获得所需的确切效果,并且确实要考虑照明设置。图示中的示例经过调整,以便在黑白呈现中传达一些问题,因此并不真实,但示例文件iridescence.blend中的设置经过调整,以便在渲染时产生令人愉悦的色彩效果。

在之前的屏幕截图显示了使用颜色渐变和噪声纹理的使用,其中我们添加了一些除法节点来缩放我们的距离到一个范围在[0,1]内,这可以用作颜色渐变的输入:

摘要
在本章中,我们看到了 Blender 缺乏编译着色器语言并不会阻止其用于设计自定义图案和着色器。Pynodes 是 Blender 节点系统的一个集成部分,我们看到了如何使用它们从简单的颜色图案到相当复杂的动画涟漪来创建效果。具体来说,我们学习了:
-
如何编写生成简单颜色图案的 Pynodes
-
如何编写生成具有法线图案的 Pynodes
-
如何编写动画 Pynodes
-
如何编写高度和坡度相关的材质
-
如何创建对入射光角度做出反应的着色器
在下一章中,我们将探讨整个渲染过程的自动化。
第八章。渲染和图像处理
在前面的章节中,我们主要关注构成 Blender 场景的各个组件的脚本方面,例如网格、灯光、材质等。在本章中,我们将转向整个渲染过程。我们将自动化这个渲染过程,以各种方式组合生成的图像,甚至将 Blender 转换为一个专门的 Web 服务器。
在本章中,你将学习如何:
-
自动化渲染过程
-
为产品展示创建多个视图
-
从复杂对象创建广告牌
-
使用 Python 图像处理库 (PIL) 处理图像,包括渲染结果
-
创建一个按需生成图像的服务器,这些图像可以用作 CAPTCHA 挑战
-
创建联系表
不同的视角——结合多个摄像机角度
到现在为止,你可能期望渲染也可以自动化,你完全正确。Blender Python API 提供了对渲染过程几乎所有参数的访问,并允许你渲染单个帧以及动画。这允许自动化许多手工操作起来繁琐的任务。
假设你已经创建了一个对象,并想创建一张从不同角度展示它的单张图像。你可以分别渲染这些图像,并在外部应用程序中合并它们,但我们将编写一个脚本,不仅渲染这些视图,而且通过使用 Blender 的图像处理能力和一个名为 PIL 的外部模块,将它们合并到一张单独的图像中。我们试图达到的效果在苏珊娜的插图中有展示,展示了她最好的所有角度。

Blender 是一个出色的工具,它不仅提供了建模、动画和渲染选项,还具有合成功能。它不擅长的一个领域是“图像处理”。当然,它有一个 UV 编辑器/图像窗口,但这个窗口非常具体地设计用于处理 UV 映射和查看图像,而不是处理它们。节点编辑器也具有复杂的图像处理能力,但没有文档化的 API,因此不能通过脚本进行配置。
当然,Blender 不能做所有的事情,而且它当然不试图与 GIMP (www.gimp.org) 等包竞争,但一些内置的图像处理功能会受到欢迎。(每个图像都可以在像素级别上进行处理,但在大图像上这会相当慢,我们仍然需要实现高级功能,如 alpha 混合或旋转图像。)
幸运的是,我们可以从 Python 访问 Blender 生成的任何图像,并且在 Python 中添加额外的包以提供额外功能并从我们的脚本中使用它们相当简单。唯一的缺点是,使用这些额外库的任何脚本都不是自动可移植的,因此用户必须检查相关的库是否可用。
我们将要使用的Python Imaging Library(PIL)是免费提供的,并且易于安装。因此,对于普通用户来说,应该不会造成任何问题。然而,由于我们可以仅使用 Blender 的Image模块来实现简单的粘贴功能(我们将在下面看到),我们确实在完整代码中提供了一个极简模块pim,它仅实现了使用我们的示例而不需要安装 PIL 所需的最基本功能。这种独立性是有代价的:我们的paste()函数比 PIL 提供的函数慢了近 40 倍,并且生成的图像只能以 TARGA(.tga)格式保存。但你可能不会注意到这一点,因为 Blender 可以很好地显示 TARGA 文件。完整代码配备了一些技巧,以便在 PIL(如果可用)和我们的替代模块不可用的情况下使用。 (这一点在书中没有展示。)
注意
Python Imaging Library (PIL)
PIL 是一个开源软件包,可以从www.pythonware.com/products/pil/index.htm免费获取。它由多个 Python 模块和一个预编译的核心库组成,该库适用于 Windows(在 Linux 上编译也足够简单,甚至可能已经包含在发行版中)。只需遵循网站上的说明进行安装(只需记住使用正确的 Python 版本安装 PIL;如果你安装了多个 Python 版本,请使用 Blender 使用的版本来安装 PIL)。
代码概要—combine.py
我们需要采取哪些步骤来创建我们的合成图像?我们将必须:
-
如有必要,创建相机。
-
将相机对准主题。
-
从所有相机渲染视图。
-
将渲染的图像合并为单个图像。
代码首先导入所有必要的模块。从 PIL 包中,我们需要Image模块,但我们以不同的名称(pim)导入它,以防止与我们将要使用的 Blender 的Image模块发生名称冲突:
from PIL import Image as pim
import Blender
from Blender import Camera, Scene, Image, Object, Mathutils, Window
import bpy
import os
我们遇到的第一个实用函数是paste()。此函数将四个图像合并成一个。图像作为文件名传递,结果保存为result.png,除非指定了另一个输出文件名。我们假设所有四个图像具有相同的尺寸,我们通过打开第一个文件作为 PIL 图像并检查其size属性(在下一行代码中突出显示)来确定尺寸。图像将通过一条带有实色的小线分隔和边框。宽度和颜色被硬编码为edge和edgecolor变量,尽管你可能考虑将它们作为参数传递:
def paste(top,right,front,free,output="result.png"):
im = pim.open(top)
w,h= im.size
edge=4
edgecolor=(0.0,0.0,0.0)
接下来,我们创建一个足够大的空图像,足以容纳四张图片以及适当的边框。我们不会专门绘制任何边框,但只是定义一个新的图像,用实色填充,然后在这张图像上粘贴四张图片,并适当地偏移:
comp = pim.new(im.mode,(w*2+3*edge,h*2+3*edge),edgecolor)
我们已经打开了顶部的图片,所以我们只需将其粘贴到合并图片的左上象限中,同时在水平和垂直方向上偏移边框宽度:
comp.paste(im,(edge,edge))
将其他三张图片粘贴的过程遵循相同的步骤:打开图片并将其粘贴到正确的位置。最后,合并后的图片被保存(突出显示)。保存的图片文件类型由其扩展名(例如,png)确定,但如果我们在save()方法中传递了格式参数,则可能会被覆盖。请注意,没有必要指定输入文件的格式,因为open()函数会根据其内容确定图像类型。
im = pim.open(right)
comp.paste(im,(w+2*edge,edge))
im = pim.open(front)
comp.paste(im,(edge,h+2*edge))
im = pim.open(free)
comp.paste(im,(w+2*edge,h+2*edge))
comp.save(output)
我们下一个函数从特定的相机渲染视图并将结果保存到文件。要渲染的相机作为 Blender 对象的名称传递(即不是底层Camera对象的名称)。第一行检索Camera对象和当前场景,并在场景中使相机成为当前状态——即将要渲染的(在下面突出显示)。setCurrentCamera()接受一个 Blender 对象,而不是Camera对象,这就是我们传递对象名称的原因。
def render(camera):
cam = Object.Get(camera)
scn = Scene.GetCurrent()
scn.setCurrentCamera(cam)
context = scn.getRenderingContext()
由于我们可能会在后台****进程中使用此函数,我们将使用渲染上下文的renderAnim()方法而不是render()方法。这是因为render()方法不能在后台进程中使用。因此,我们将当前帧以及起始帧和结束帧设置为相同的值,以确保renderAnim()只会渲染一个帧。我们还设置displayMode为0以防止弹出额外的渲染窗口(在下一代码片段中突出显示):
frame = context.currentFrame()
context.endFrame(frame)
context.startFrame(frame)
context.displayMode=0
context.renderAnim()
renderAnim()方法将帧渲染到文件中,因此我们的下一个任务是检索我们刚刚渲染的帧的文件名。确切的文件名格式可以由用户在用户****首选项窗口中指定,但通过显式调用getFrameFilename(),我们确保我们得到正确的一个:
filename= context.getFrameFilename()
由于每个渲染的相机视图的帧编号都将相同,我们必须重命名此文件,否则它将被覆盖。因此,我们创建一个合适的新名称,由我们刚刚渲染的帧的路径和相机的名称组成。我们使用来自 Python 的os.path模块的可移植路径操作函数,以确保在 Windows 上和在 Linux 上都能正常工作。
由于我们的脚本可能已经被使用,我们尝试删除任何具有相同名称的现有文件,因为在 Windows 下将文件重命名为现有文件名将失败。当然,可能还没有文件——这种情况我们在try块中进行了保护。最后,我们的函数返回新创建的文件名:
camera = os.path.join(os.path.dirname(filename),camera)
try:
os.remove(camera)
except:
pass
os.rename(filename,camera)
return camera
下一个重要任务是设置相机框架,即选择一个合适的 相机角度,以便所有相机都能以最佳方式适应图片中的可用区域。我们希望所有相机的角度都相同,以便从所有观察角度为观众提供一致的角度。当然,这可以手动完成,但这很繁琐,所以我们定义了一个函数来为我们完成这项工作。
我们这样做是通过取我们主题的 边界框 并通过假设这个边界框必须刚好填满我们的视图来确定相机的观察角度。因为我们可以计算相机到边界框中心的距离,所以观察角度必须与边界框和相机距离形成的三角形的锐角相同。

我们为所有相机计算这个角度,然后为每个相机设置最宽的角度,以防止对主题的不希望出现的裁剪。请注意,如果相机离主题太近(或者等价地,如果主题太大),则此算法可能会失败,在这种情况下可能会发生一些裁剪。
代码在数学上相当复杂,所以我们首先导入必要的函数:
from math import asin,tan,pi,radians
该函数本身将接受一个 Blender 对象(相机)名称列表和一个边界框(一个向量列表,每个边界框的一个角对应一个向量)。它首先确定所有三个轴和宽度的边界框的最小和最大范围。我们假设我们的主题位于原点中心。maxw 将保存任何轴上的最大宽度。
def frame(cameras,bb):
maxx = max(v.x for v in bb)
maxy = max(v.y for v in bb)
maxz = max(v.z for v in bb)
minx = min(v.x for v in bb)
miny = min(v.y for v in bb)
minz = min(v.z for v in bb)
wx=maxx-minx
wy=maxy-miny
wz=maxz-minz
m=Mathutils.Vector((wx/2.0,wy/2.0,wz/2.0))
maxw=max((wx,wy,wz))/2.0
接下来,我们获取每个 Camera 对象的世界空间坐标,以计算到边界框中点的距离 d(下一行代码中突出显示)。我们存储最大宽度和距离的商:
sins=[]
for cam in cameras:
p=Mathutils.Vector(Object.Get(cam).getLocation('worldspace'))
d=(p-m).length
sins.append(maxw/d)
我们取计算出的最大商(这将相当于最宽的角度),通过计算反正弦来确定角度,并通过设置 Camera 对象的 lens 属性来完成。相机观察角度与 Blender 中 lens 属性值之间的关系复杂且很少被记录(lens 包含理想镜头焦距的近似值)。显示的公式是从 Blender 源代码中取出的(突出显示)。
maxsin=max(sins)
angle=asin(maxsin)
for cam in cameras:
Object.Get(cam).getData().lens = 16.0/tan(angle)
另一个便利函数是定义四个相机并将它们适当地围绕原点排列到场景中的函数。在原则上,这个函数很简单,但它稍微复杂一些,因为它试图重用具有相同名称的现有相机,以防止在脚本运行多次时出现不希望的相机数量激增。cameras 字典按名称索引,并包含位置、旋转和镜头值列表:
def createcams():
cameras = {
'Top' : (( 0.0, 0.0,10.0),( 0.0,0.0, 0.0),35.0),'Right': ((10.0, 0.0, 0.0),(90.0,0.0,90.0),35.0),
'Front': (( 0.0,-10.0, 0.0),(90.0,0.0, 0.0),35.0),'Free' : (( 5.8, -5.8, 5.8),(54.7,0.0,45.0),35.0)
}
对于cameras字典中的每个相机,我们检查它是否已经作为一个 Blender 对象存在。如果存在,我们检查 Blender 对象是否与其关联了一个Camera对象。如果后者不成立,我们创建一个与顶级对象同名的透视相机(高亮显示),并通过link()方法将其与顶级对象关联起来:
for cam in cameras:
try:
ob = Object.Get(cam)
camob = ob.getData()
if camob == None:
camob = Camera.New('persp',cam)
ob.link(camob)
如果没有现有的顶级对象,我们创建一个并与之关联一个新的透视Camera对象:
except ValueError:
ob = Object.New('Camera',cam)
Scene.GetCurrent().link(ob)
camob = Camera.New('persp',cam)
ob.link(camob)
我们通过设置location、rotation和lens属性来结束。请注意,旋转角度是以弧度表示的,因此我们将它们从表格中更直观的度数(高亮显示)转换过来。最后,我们调用Redraw()方法,以便在用户界面中显示这些更改:
ob.setLocation(cameras[cam][0])
ob.setEuler([radians(a) for a in cameras[cam][1]])
camob.lens=cameras[cam][2]
Blender.Redraw()
最后,我们定义一个run()方法,将所有组件连接起来。它确定活动对象,然后遍历相机名称列表以渲染每个视图,并将生成的文件名添加到列表中(高亮显示):
def run():
ob = Scene.GetCurrent().objects.active
cameras = ('Top','Right','Front','Free')
frame(cameras,ob.getBoundBox())
files = []
for cam in cameras:
files.append(render(cam))
我们将组合的图片放在与单个视图相同的目录中,并将其命名为result.png:
outfile = os.path.join(os.path.dirname(files[0]),'result.png')
然后,我们调用我们的paste()函数,通过星号(*)运算符将组件文件名展开为单独的参数,并以加载结果文件作为 Blender 图像并在图像编辑器窗口中显示(以下高亮显示)作为结束。reload是必要的,以确保刷新具有相同名称的先前图像:
paste(*files,output=outfile)
im=Image.Load(outfile)
bpy.data.images.active = im
im.reload()
Window.RedrawAll()
run()函数故意没有创建任何相机,因为用户可能希望自行完成。最终脚本本身确实负责创建相机,但这可能很容易更改,并且通常很简单。在检查是否可以独立运行之后,它只是创建相机并调用run方法:
if __name__ == "__main__":
createcams()
run()
完整的代码作为combine.py文件位于combine.blend中。
工作流程——如何展示你的模型
该脚本可以使用以下方式使用:
-
将你的主题放置在原点(位置(0,0,0))。
-
创建合适的照明条件。
-
运行
combine.py。
可以通过Alt + P将脚本加载到文本编辑器中运行,但你也可以将脚本放入 Blender 的scripts目录中,以便从脚本 | 渲染菜单中访问。通过脚本 | 渲染菜单访问。
现在,创建电影带——从动画中创建电影带
将多个相机视图拟合到单个图像只是多个图像可能有效组合成单个图像的一个例子。另一个例子是当我们想展示动画的帧,而我们没有访问播放动画的设施时。在这种情况下,我们希望展示类似电影带的东西,我们将每第十帧的小版本组合到一张图像上。以下插图显示了示例。
虽然组合的图像比多相机视图中的图像多,但创建此类电影带的代码相当相似。

我们首先开发的函数是strip(),它接受要组合的图像文件名列表和一个可选的name,该name将被赋予组合图像。第三个可选参数是cols,它是组合图像中的列数。默认值为四,但对于较长的序列,可能更自然地使用横向纸张并在此处使用更高的值。该函数将返回一个包含组合图像的 Blender Image对象。
我们再次使用pim模块,如果 PIL 模块可用,则pim是 PIL 模块的别名,如果 PIL 不可用,则将引用我们的简化实现。与之前图像组合代码的重要区别被突出显示。第一个突出部分显示了如何根据行数、列数以及围绕图像的彩色边缘所需的像素数来计算组合图像的尺寸。第二个突出行显示了在目标图像中粘贴图像的位置:
def strip(files,name='Strip',cols=4):
rows = int(len(files)/cols)
if len(files)%int(cols) : rows += 1
im = pim.open(files.pop(0))
w,h= im.size
edge=2
edgecolor=(0.0,0.0,0.0)
comp = pim.new(im.mode,(w*cols+(cols+1)*edge,h*rows+(rows+1)*edge),edgecolor)
for y in range(rows):
for x in range(cols):
comp.paste(im,(edge+x*(w+edge),edge+y*(h+edge)))
if len(files)>0:
im = pim.open(files.pop(0))
else:
comp.save(name,format='png')
return Image.Load(name)
我们在这里定义的render()函数将接受要跳过的帧数作为参数,并将渲染从起始帧到结束帧之间的任意数量的帧。这些起始帧和结束帧可以由用户在渲染按钮中设置。渲染按钮还包含一个步进值,但此值不提供给 Python API。这意味着我们的函数比我们希望的更冗长,因为我们必须创建一个循环来自己渲染每一帧(在下一代码中突出显示)而不是仅仅调用renderAnim()。因此,我们必须操作渲染上下文的startFrame和endFrame属性(如之前所述),但我们注意在返回渲染图像的文件名列表之前恢复这些属性。如果我们不需要任何程序控制来设置skip值,我们可以简单地用一个对renderAnim()的调用替换对render()的调用:
def render(skip=10):
context = Scene.GetCurrent().getRenderingContext()
filenames = []
e = context.endFrame()
s = context.startFrame()
context.displayMode=0
for frame in range(s,e+1,skip):
context.currentFrame(frame)
context.startFrame(frame)
context.endFrame(frame)
context.renderAnim()
filenames.append(context.getFrameFilename())
context.startFrame(s)
context.endFrame(e)
return filenames
定义了这些函数后,脚本本身现在只需调用render()来创建图像,并调用strip()来组合它们。结果 Blender 图像被重新加载以强制更新,如果已存在具有相同名称的图像,并且所有窗口都会提示重新绘制自己(突出显示):
def run():
files = render()
im=strip(files)
bpy.data.images.active = im
im.reload()
Window.RedrawAll()
if __name__ == "__main__":
run()
完整代码作为strip.py在combine.blend中可用。
工作流程——使用 strip.py
现在可以按照以下步骤创建动画帧条:
-
创建您的动画。
-
从文本编辑器运行
strip.py。 -
组合图像将在 UV 编辑器/图像窗口中显示。
-
使用您选择的名称保存图片。
渲染广告牌
场景中的现实主义通常是通过提供大量细节来实现的,尤其是在自然物体中。然而,这种现实主义是有代价的,因为详细的模型通常包含许多面,这些面消耗内存并需要时间来渲染。一个逼真的树木模型可能包含多达五十万个面,因此这样的森林几乎不可能渲染,尤其是在快速节奏的游戏场景中。
Blender 附带了一些工具,可以在渲染多个物体副本时减少所需的内存量;不同的网格对象可能引用相同的网格数据,正如DupliVerts(在父对象的每个顶点位置复制的子对象。有关更多信息,请参阅wiki.blender.org/index.php/Doc:Manual/Modeling/Objects/Duplication/DupliVerts)。在粒子系统中复制对象也允许我们创建许多相同对象的实例,而无需实际复制所有数据。这些技术可能节省大量内存,但详细物体在渲染时仍可能需要大量的 CPU 资源,因为细节仍然需要被渲染。
广告牌是一种技术,用于将复杂物体的图片应用到简单物体上,例如单个正方形面,并按需要复制此简单物体。图片必须有合适的透明度,否则每个物体可能会以不真实的方式遮挡其他物体。除此之外,这项技术相当简单,可以节省大量的渲染时间,并且对于放置在中等距离或更远处的物体,它将给出相当逼真的效果。Blender 的粒子系统可以使用广告牌,要么是带有图像的简单正方形,要么是我们自己将图像应用到简单物体上,并使用该物体作为复制对象。后者也适用于 dupliverted 对象。
诀窍是生成一个具有合适光照的图像,用作可以应用到正方形上的图像。实际上,我们希望创建两个图像:一个从正面拍摄,一个从右侧拍摄,并构建一个由两个相互垂直的正方形面组成的物体,这两个面应用了上述图像。这样的物体将给我们提供有限的自由度,在场景中放置相机时,它们不必从单一方向被看到。这对于具有大致圆柱对称的物体,如树木或摩天大楼,效果很好,但效果相当有效。
构建此类物体的工作流程足够复杂,足以需要自动化:
-
将两个相机放置在详细物体的前方和右侧。
-
将两个相机调整到相同的视角,以捕捉所有物体。
-
使用带有预乘 alpha 通道且无天空的透明图像进行渲染。
-
构建一个由两个垂直正方形组成的简单物体。
-
将每个渲染的图像应用到正方形上。
-
隐藏详细物体以避免渲染。
-
可选地,在粒子系统中复制简单对象(用户可以选择不自动化这一部分,而是手动放置简单对象)。
第三步中提到的“预乘”可能需要一些解释。显然,我们复杂对象的渲染图像不需要显示任何背景天空,因为它们的复制克隆可能位于任何位置,并且可能通过它们的透明部分显示天空的不同部分。正如我们将看到的,这很简单就能完成,但当我们简单地渲染一个透明图像并在后面叠加时,图像可能会有不美观的刺眼光边。
避免这种情况的方法是通过乘以 alpha 值和渲染上下文来调整渲染颜色,渲染上下文具有必要的属性来指示这一点。我们不应该忘记在使用它们作为纹理时标记生成的图像为“预乘”,否则它们看起来会太暗。差异在下图中得到了说明,其中我们合成了并放大了左侧正确预乘的一半和右侧渲染的天空的一半。树的树干在右侧显示了一个亮边。(有关更多详细信息,请参阅 Roger Wickes 的出色书籍《Blender 合成基础》)。

在这些和随后的插图中所使用的山毛榉树是一个高度详细的三维模型(超过 30,000 个面),由 Yorik van Havre 使用免费的植物建模软件包 ngPlant 创建。(请访问他的网站以查看更多精美示例:yorik.uncreated.net/greenhouse.html)以下第一组图像显示了从前面拍摄的山毛榉树以及左侧两个广告牌的前视渲染结果。(由于预乘,颜色略暗)。

下面的截图显示了从右侧渲染的同一棵山毛榉树,以及左侧广告牌的面向右侧的渲染。正如所见,从这个角度和这个特写来看,渲染效果当然并不完美,但保留了合理的三维效果。

为了给广告牌的结构留下印象,下一张截图显示了应用了渲染图像的两个面。透明度故意降低,以显示单个面。

我们的首要挑战是重用我们为生成联系表所编写的部分功能。这些功能在一个名为 combine.py 的文本缓冲区中,我们没有将其保存到外部文件中。我们将在与 combine.py 相同的 .blend 文件中创建一个新的文本缓冲区 cardboard.py,并希望像引用外部模块一样引用后者。如果找不到外部文件,Blender 会为我们提供这种可能性,因为它会在当前文本缓冲区中搜索模块。
由于内部文本缓冲区没有关于它们上次修改时间的信息,我们必须确保加载最新版本。这正是reload()函数要处理的。如果我们不这样做,Blender 将无法检测combine.py是否已更改,这可能导致我们使用它的较旧编译版本:
import combine
reload(combine)
我们不会重用combine.py中的render()函数,因为我们将对应用于广告牌的渲染图像有不同的要求。如前所述,我们必须确保在部分透明的地方不会出现任何亮边,因此我们必须预先乘以 alpha 通道(高亮显示)。在从该函数返回之前,我们再次将渲染上下文重置为'渲染天空',因为这很容易忘记手动再次打开它,你可能会浪费时间 wondering 你的天空去哪了:
def render(camera):
cam = Object.Get(camera)
scn = Scene.GetCurrent()
scn.setCurrentCamera(cam)
context = scn.getRenderingContext()
frame = context.currentFrame()
context.endFrame(frame)
context.startFrame(frame)
context.displayMode=0
context.enablePremultiply()
context.renderAnim()
filename= context.getFrameFilename()
camera = os.path.join(os.path.dirname(filename),camera)
try:
os.remove(camera) # remove otherwise rename fails on windows
except:
pass
os.rename(filename,camera)
context.enableSky()
return camera
每个渲染的图像都需要转换成适合应用于 UV 映射正方形的材料。函数imagemat()正是为此而设计的;它将接受一个 Blender Image对象作为参数,并返回一个Material对象。这个材料将被完全透明(高亮显示),但这种透明度和颜色将由我们分配给第一个纹理通道(第二行高亮显示)的纹理进行修改。纹理类型设置为Image,因为我们渲染这些图像时使用了预乘 alpha 通道,所以我们使用setImageFlags()方法来指示我们想要使用这个 alpha 通道,并将图像的premul属性设置为True:
def imagemat(image):
mat = Material.New()
mat.setAlpha(0.0)
mat.setMode(mat.getMode()|Material.Modes.ZTRANSP)
tex = Texture.New()
tex.setType('Image')
tex.image = image
tex.setImageFlags('UseAlpha')
image.premul=True
mat.setTexture(0,tex,Texture.TexCo.UV,Texture.MapTo.COL|Texture.MapTo.ALPHA)
return mat
我们将应用材料的每个面都必须进行 UV 映射。在这种情况下,这将是最简单的映射,因为正方形面将正好映射到一个矩形图像上。这通常被称为重置映射,因此我们定义的函数被称为reset()。它将接受一个 Blender MFace对象,我们假设它是一个四边形,并将它的uv属性设置为二维向量的列表,每个顶点一个。这些向量将每个顶点映射到图像的一个角落:
def reset(face):
face.uv=[vec(0.0,0.0),vec(1.0,0.0),vec(1.0,1.0),vec(0.0,1.0)]
cardboard()函数负责从作为参数传递的两个Image对象中构建实际的Mesh对象。它首先构建两个沿 z 轴交叉的正方形面。下一步是添加一个 UV 层(高亮显示)并将其设置为活动层:
def cardboard(left,right):
mesh = Mesh.New('Cardboard')
verts=[(0.0,0.0,0.0),(1.0,0.0,0.0),(1.0,0.0,1.0),(0.0,0.0,1.0),
(0.5,-0.5,0.0),(0.5,0.5,0.0),(0.5,0.5,1.0),(0.5,-0.5,1.0)]
faces=[(0,1,2,3),(4,5,6,7)]
mesh.verts.extend(verts)
mesh.faces.extend(faces)
mesh.addUVLayer('Reset')
mesh.activeUVLayer='Reset'
接下来,我们从两个图像中构建合适的材料,并将这些材料分配给网格的materials属性。然后,我们重置两个面的 UV 坐标,并将材料分配给它们(高亮显示)。在返回之前,我们更新网格以使更改可见:
mesh.materials=[imagemat(left),imagemat(right)]
reset(mesh.faces[0])
reset(mesh.faces[1])
mesh.faces[0].mat=0
mesh.faces[1].mat=1
mesh.update()
return mesh
为了替换粒子系统的副本对象的网格,我们实现了一个实用函数 setmesh()。它接受与粒子系统相关联的对象的名称和一个 Mesh 对象作为参数。它通过名称定位对象并检索第一个粒子系统(在下一段代码片段中突出显示)。副本对象存储在 duplicateObject 属性中。请注意,这是一个 只读 属性,因此目前没有从 Python 中替换对象的可能性。但我们可以替换对象的 数据,这正是我们通过将 Mesh 对象传递给 link() 方法来做的。发射对象和粒子系统的副本对象都发生了变化,因此我们在开始重绘所有 Blender 窗口之前,通过在它们上调用 makeDisplayList() 方法来确保更改是可见的:
def setmesh(obname,mesh):
ob = Object.Get(obname)
ps = ob.getParticleSystems()[0]
dup = ps.duplicateObject
dup.link(mesh)
ob.makeDisplayList()
dup.makeDisplayList()
Window.RedrawAll()
run() 函数封装了将活动对象转换为一系列广告牌并将它们分配给粒子系统所需的所有工作。首先,我们检索活动对象的引用并确保它在渲染时是可见的:
def run():
act_ob = Scene.GetCurrent().objects.active
act_ob.restrictRender = False
下一步是在渲染广告牌之前,使场景中的其余对象不可见。有些对象可能已经被用户设置为不可见,因此,我们必须记住这些状态,以便稍后可以恢复它们。此外,我们不改变灯具或相机的状态,因为使这些对象不可见将导致我们得到全黑的图像(突出显示):
renderstate = {}
for ob in Scene.GetCurrent().objects:
renderstate[ob.getName()] = ob.restrictRender
if not ob.getType() in ('Camera','Lamp' ):
ob.restrictRender = True
act_ob.restrictRender = False
一切设置完毕,只渲染活动对象后,我们使用合适的相机渲染前后图像,就像我们在 combine.py 脚本中所做的那样。实际上,这里我们重用了 frame() 函数(突出显示):
cameras = ('Front','Right')
combine.frame(cameras,act_ob.getBoundBox())
images={}
for cam in cameras:
im=Image.Load(render(cam))
im.reload()
images[cam]=im
bpy.data.images.active = im
Window.RedrawAll()
然后我们在从两张图片中构建新网格之前,恢复场景中所有对象的前一个可见性。我们通过在调用 run() 之前从 combine 模块调用 createcams() 函数来创建渲染广告牌所需的相机(如果这些相机尚未存在):
for ob in Scene.GetCurrent().objects:
ob.restrictRender = renderstate[ob.getName()]
mesh = cardboard(images['Front'],images['Right'])
act_ob.restrictRender = True
setmesh('CardboardP',mesh)
代码的最后几行通过在调用 run() 之前从 combine 模块调用 createcams() 函数来创建渲染广告牌所需的相机(如果这些相机尚未存在):
if __name__ == "__main__":
combine.createcams()
run()
完整的代码作为 cardboard.py 存放在 combine.blend 中。
工作流程—使用 cardboard.py
假设你有一个希望转换为一系列广告牌的高多边形对象,可能的工作流程如下:
-
创建一个名为
CardboardP的对象。 -
将粒子系统分配给此对象。
-
创建一个虚拟立方体。
-
将虚拟立方体分配为
CarboardP对象的第一个粒子系统上的副本对象。 -
选择(激活)要渲染为一系列广告牌的对象。
-
运行
cardboard.py。 -
选择原始相机并渲染场景。
当然,脚本可以被修改以省略自动替换重复对象网格,如果这样做更合适的话。例如,如果我们想使用 dupliverted 对象而不是粒子,我们只需生成 cardboard 对象并将其网格分配给 dupliverted 对象。如果我们确实使用粒子系统,我们可能不希望所有复制的对象都精确地以相同的方式定位。因此,我们可能会随机化它们的旋转,以下截图展示了完成这一目标的示例设置:

下一个截图展示了从树模型创建的 billboard 的应用,并在粒子系统中使用:

生成 CAPTCHA 验证码
在许多情况下,例如博客、论坛和在线调查(仅举几个例子),网站运营商希望防止垃圾邮件机器人自动发布内容,同时又不想让人类访客承担注册和认证的负担。在这种情况下,向访客提供所谓的 CAPTCHA 验证码(en.wikipedia.org/wiki/Captcha)已成为一种常见做法。CAPTCHA 验证码(或简称验证码)在 simplest form 是一张对计算机来说难以识别,但对人类来说却很容易辨认的图片,通常是扭曲或模糊的文字或数字。
当然,没有方法是万无一失的,CAPTCHA 也并非没有缺陷,也不是对不断增长的计算能力免疫的,但它们仍然非常有效。尽管目前的共识是简单的模糊和着色方案不足以完成任务,但计算机在字符轻微重叠时仍然难以分离单词中的单个字符,而人类几乎不会遇到这个问题。
考虑到这些论点,文本的 3D 渲染可能是一个极好的应用,因为假设在合适的照明条件下(即强烈的阴影)的词语的三维呈现比二维文本更难解读。我们的挑战是设计一个服务器,它将响应请求以渲染某些文本的三维图像。
我们将设计我们的服务器作为一个 web 服务器,它将响应指向其 URL 的请求,形式为 http:<hostname>:<port>/captcha?text=<sometext>,并将返回一个 PNG 图像——该文本的 3D 渲染。这样,它将很容易集成到某些软件(如博客)的架构中,这些软件可以通过简单地通过 HTTP 访问我们的服务器来轻松地整合这一功能。以下是一个生成的验证码示例:

CAPTCHA 服务器的设计
通过使用 Python 完整发行版中可用的模块,实现 HTTP 服务器的任务并不像看起来那么令人畏惧。我们的 Captcha 服务器将基于 Python 的BaseHTTPServer模块中提供的类,因此我们首先导入这个模块以及一些额外的实用模块:
import BaseHTTPServer
import re
import os
import shutil
BaseHTTPServer模块定义了两个类,这两个类共同构成了一个完整的HTTP服务器实现。BaseHTTPServer类实现了基本的服务器,该服务器将在某个网络端口上监听传入的HTTP请求,我们将直接使用这个类。
在收到一个有效的HTTP请求后,BaseHTTPServer将把这个请求分发给请求处理器。我们基于BaseHTTPRequestHandler实现的这种请求处理器相当简洁,因为它预期只处理形式为captcha?text=abcd的 URI 的GET和HEAD请求。因此,我们只需要覆盖基类的do_GET()和do_HEAD()方法。
预期HEAD请求只返回请求对象的头信息,而不是其内容,以节省在内容自上次请求以来未更改时的时间(这可以通过检查Last-Modified头信息来确定)。我们忽略了这样的小细节;当我们收到HEAD请求时,我们将只返回头信息,但仍然会生成一个全新的图片。这有点浪费,但确实使代码简单。如果性能很重要,可以设计另一种实现方式。
我们的实现首先定义了一个do_GET()方法,它只是调用do_HEAD()方法,该方法将生成一个 Captcha 挑战并返回头信息给客户端。do_GET()随后将do_HEAD()返回的文件对象的全部内容复制到输出文件,例如请求处理器的对象(突出显示),该对象随后将此内容返回给客户端(例如浏览器):
class CaptchaRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
def do_GET(self):
f=self.do_HEAD()
shutil.copyfileobj(f,self.wfile)
f.close()
do_HEAD()方法首先通过调用gettext()方法(将在代码中稍后定义,并突出显示)来确定我们是否收到了一个有效的请求(即形式为captcha?text=abcd的 URI)。如果 URI 无效,gettext()将返回None,并且do_HEAD()将通过调用基类的send_error()方法向客户端返回一个文件未找到错误:
def do_HEAD(self):
text=self.gettext()
if text==None:
self.send_error(404, "File not found")
return None
如果请求了一个有效的 URI,实际的图片将通过captcha()方法生成,该方法将返回生成的图片的文件名。如果由于任何原因该方法失败,将向客户端返回一个内部服务器错误:
try:
filename = self.captcha(text)
except:
self.send_error(500, "Internal server error")
return None
如果一切顺利,我们打开图像文件,向客户端发送200响应(表示操作成功),并返回一个Content-type头,表明我们将返回一个png图像。接下来,我们使用fstat()函数和打开文件句柄的编号作为参数来检索生成图像的长度,并将其作为Content-Length头返回(突出显示),然后是修改时间和一个空行,表示头部的结束,在返回打开的文件对象f之前:
f = open(filename,'rb')
self.send_response(200)
self.send_header("Content-type", 'image/png')
fs = os.fstat(f.fileno())
self.send_header("Content-Length", str(fs[6]))
self.send_header("Last-Modified",self.date_time_string(fs.st_mtime))
self.end_headers()
return f
gettext()方法通过将请求与正则表达式匹配来验证传递给我们的请求处理程序在路径变量中的请求是否为有效的 URI。Python 的re模块中的match()函数如果正则表达式匹配,将返回一个MatchObject,如果不匹配,则返回None。如果确实存在匹配项,我们返回第一个匹配组的内容(匹配正则表达式中括号内的字符,在我们的情况下是text参数的值),否则返回None:
def gettext(self):
match = re.match(r'^.*/captcha\?text=(.*)$',self.path)
if match != None:
return match.group(1)
return None
现在我们来到了 Blender 特有的任务,即实际生成将被返回为png图像的渲染 3D 文本。captcha()方法将接受要渲染的文本作为参数,并返回生成的图像的文件名。我们将假设我们从.blend文件中运行captcha.py时,灯光和相机已经正确设置,以便以可读的方式显示我们的文本。因此,captcha()方法只需配置一个合适的Text3d对象并将其渲染即可。
它的第一个任务是确定当前场景并检查是否存在名为Text的对象可以重用(突出显示)。请注意,在场景中存在其他对象以进一步模糊显示是完全有效的:
def captcha(self,text):
import Blender
scn = Blender.Scene.GetCurrent()
text_ob = None
for ob in scn.objects:
if ob.name == 'Text' :
text_ob = ob.getData()
break
如果没有可重用的Text3d对象,将创建一个新的:
if text_ob == None:
text_ob = Blender.Text3d.New('Text')
ob=scn.objects.new(text_ob)
ob.setName('Text')
下一步是将Text3d对象的文本设置为传递给captcha()方法的参数,并通过设置其挤出深度使其成为 3D。我们还调整了字符的宽度和缩短它们之间的间距以降低分离度。添加一个小斜面将使字符的轮廓变得柔和,这可能会增加在光线微妙(突出显示)的情况下机器人识别字符的难度。我们本可以选择使用对机器人来说甚至更难阅读的字体,而这将是设置此字体的位置(参见以下信息框)。
注意
缺少某些内容
Blender 的 API 文档有一个小的遗漏:似乎没有方法可以为Text3d对象配置不同的字体。然而,有一个未记录的setFont()方法,它将接受一个Font对象作为参数。实现字体更改的代码看起来像这样:
fancyfont=Text3d.Load( '/usr/share/fonts/ttf/myfont.ttf') text_ob.setFont(fancyfont)
我们选择不包含此代码,部分原因是因为它没有文档说明,但主要是因为可用的字体在不同系统之间差异很大。如果你有合适的字体,请务必使用它。例如,类似于手写的脚本字体可能会进一步提高计算机的标准。
最后一步是更新 Blender 中该对象的显示列表,以便我们的更改将被渲染:
text_ob.setText(text)
text_ob.setExtrudeDepth(0.3)
text_ob.setWidth(1.003)
text_ob.setSpacing(0.8)
text_ob.setExtrudeBevelDepth(0.01)
ob.makeDisplayList()
当我们的Text3d对象就位后,我们的下一个任务是将图像渲染到文件中。首先,我们从当前场景中检索渲染上下文,并将displayMode设置为0以防止弹出额外的渲染窗口:
context = scn.getRenderingContext()
context.displayMode=0
接下来,我们设置图像大小,并指出我们想要一个png格式的图像。通过启用 RGBA 并将 alpha 模式设置为2,我们确保不会显示任何天空,并且我们的图像将有一个漂亮的透明背景:
context.imageSizeX(160)
context.imageSizeY(120)
context.setImageType(Blender.Scene.Render.PNG)
context.enableRGBAColor()
context.alphaMode=2
尽管我们只渲染静态图像,但我们仍将使用渲染上下文的renderAnim()方法,因为否则结果将不会渲染到文件中,而是渲染到缓冲区中。因此,我们将动画的开始帧和结束帧设置为 1(就像当前帧一样),以确保我们只生成一个帧。然后我们使用getFrameFilename()方法返回渲染帧的文件名(包括完整路径)(高亮显示)。然后我们存储这个文件名并将其作为结果返回:
context.currentFrame(1)
context.sFrame=1
context.eFrame=1
context.renderAnim()
self.result=context.getFrameFilename()
return self.result
脚本的最后一部分定义了一个run()函数来启动 Captcha 服务器,并在脚本作为独立程序运行时调用此函数(即,不是作为模块包含)。通过这种方式定义run()函数,我们可以封装常用的服务器默认设置,例如要监听的端口号(高亮显示),同时允许在需要不同设置时重用模块:
def run(HandlerClass = CaptchaRequestHandler,
ServerClass = BaseHTTPServer.HTTPServer,
protocol="HTTP/1.1"):
port = 8080
server_address = ('', port)
HandlerClass.protocol_version = protocol
httpd = ServerClass(server_address, HandlerClass)
httpd.serve_forever()
if __name__ == '__main__':
run()
完整代码作为captcha.py文件存储在captcha.blend文件中,服务器可以通过多种方式启动:从文本编辑器(使用Alt + P),从菜单脚本 | 渲染 | captcha,或者通过从命令行以后台模式调用 Blender。要再次停止服务器,需要终止 Blender。通常,这可以通过在控制台或 DOSbox 中按Ctrl + C来完成
注意
警告
注意,由于这个服务器响应来自任何人的请求,它远非安全。至少,它应该在防火墙后面运行,只允许需要 Captcha 挑战的服务器访问它。在将其运行在任何可能从互联网访问的位置之前,你应该仔细考虑你的网络安全问题!
摘要
在本章中,我们自动化了渲染过程,并学习了如何在不需要外部图像编辑程序的情况下对图像执行多项操作。我们学习了:
-
如何自动化渲染过程
-
如何为产品展示创建多个视图
-
如何从复杂对象创建广告牌
-
如何使用 Python Imaging Library (PIL)操作图像,包括渲染结果
-
如何创建一个按需生成图像的服务器,这些图像可能被用作 CAPTCHA 挑战
在最后一章,我们将探讨一些日常维护任务。
第九章. 扩展您的工具集
本章更多地是关于如何通过扩展 Blender 的功能来简化日常使用,而不是渲染。它使用了一些需要安装的外部库,并且在某些时候,所使用的 Python 脚本可能对新手来说读起来有些困难。此外,从艺术家的角度来看,这些脚本可能视觉上不那么吸引人,因为这些脚本不适合制作精美的插图。尽管如此,这些脚本确实增加了真正的有用功能,特别是对于脚本开发者来说,所以请继续阅读。

在本章中,我们将学习如何:
-
列出并归档如图像映射等资源
-
使用 FTP 自动发布渲染图像
-
使用正则表达式搜索扩展内置编辑器的功能
-
使用 Psyco 即时编译器加速计算
-
使用 Subversion 为您的脚本添加版本控制
发布到网络及更远——使用 FTP 发布完成的渲染
只要渲染图像在文件系统中可见,我们就可以将其保存到任何位置,但并非所有平台都提供通过本地目录(文件夹)使远程 FTP 服务器可访问的可能性。这个脚本为我们提供了一个简单选项,可以将渲染图像存储在远程 FTP 服务器上,并记住服务器名称、用户名和(可选)密码,以便以后重用。
我们将要使用的文件传输协议(FTP)比例如HTTP协议要复杂一些,因为它使用了多个连接。幸运的是,对于 FTP 客户端的所有复杂性,Python 标准模块ftplib都很好地封装了。我们不仅导入了这个模块的FTP类,还导入了其他一些标准 Python 模块,特别是用于路径名操作的os.path模块和用于读取标准.netrc文件(这使我们能够在需要时将密码存储在脚本之外,以便登录 FTP 服务器)。在需要的地方,我们将讨论每个模块。
from ftplib import FTP
import os.path
import re
import netrc
import tempfile
from Blender import Image,Registry,Draw
Python 几乎达到了平台无关性的极限,但当然,有时也有一些没有完全覆盖的复杂性。例如,我们想要使用 FTP 程序(和其他程序)常用的.netrc文件中存储的用户名和密码,FTP 客户端期望这个文件位于用户的家目录中,它希望能在环境变量HOME中找到。然而,在 Windows 上,家目录的概念并没有那么明确,存在不同的方案来存储仅限于单个用户的数据;并非每个 Python 实现都以相同的方式解决这个问题。
因此,我们定义了一个小的实用函数来检查环境中是否存在HOME变量(在 Unix-like 操作系统和一些 Windows 版本中总是存在)。如果没有,它会检查是否存在USERPROFILE变量(在包括 XP 在内的大多数 Windows 版本中都存在,通常指向目录C:\Documents and Settings\<yourusername>)。如果存在,它将HOME变量设置为这个USERPROFILE变量的内容:
def sethome():
from os import environ
if not 'HOME' in environ:
if 'USERPROFILE'in environ:
environ['HOME'] = environ['USERPROFILE']
我们接下来的任务是找出用户想要将渲染结果上传到哪个 FTP 服务器。我们将这个信息存储在 Blender 注册表键中,这样每次用户想要上传渲染时,我们就不需要打扰用户进行提示。getftphost()函数接受一个名为reuse的参数,如果设置为False,则可以用来清除这个键(允许选择不同的 FTP 服务器),但将用户界面重写以提供这样的选项留给读者作为练习。
实际的代码从从注册表中检索密钥开始(如果需要从磁盘检索,因此使用True参数,已高亮显示)。如果没有密钥存在或者它不包含主机条目,我们会通过弹出窗口提示用户输入 FTP 服务器的名称。如果用户没有指定,我们会通过抛出异常来退出。否则,我们将主机名存储在主机条目中——首先创建字典(如果不存在)并将此字典存储在 Blender 的注册表中。最后,我们返回存储的主机名。
def getftphost(reuse=True):
dictname = 'ftp'
if reuse == False:
Registry.RemoveKey(dictname)
d = Registry.GetKey(dictname,True)
if d == None or not 'host' in d:
host = Draw.PupStrInput("Ftp hostname:", "", 45)
if host == None or len(host) == 0 :
raise Exception("no hostname specified")
if d == None :
d ={}
d['host'] = host
Registry.SetKey(dictname,d,True)
return d['host']
我们还需要另一个实用函数来确保 Blender 图像作为最后渲染的图像存储在磁盘上,因为以Render Result命名的图像已经作为一个图像存在,但这个图像并没有自动写入磁盘。imagefilename()函数接受一个 Blender 图像作为参数,首先检查它是否有一个与之关联的有效文件名(已高亮显示)。如果没有,它会从图像的名称创建一个文件名,通过添加.tga扩展名(图像只能保存为 TARGA 文件)。然后从这个文件名和temp目录的路径构造完整的路径。现在,当存在一个有效的文件名时,它会保存并调用save()方法来返回文件名:
def imagefilename(im):
filename = im.getFilename()
if filename == None or len(filename) == 0:
filename = im.getName()+'.tga'
filename = os.path.join(tempfile.gettempdir(),filename)
im.setFilename(filename)
im.save()
return filename
当我们将文件上传到 FTP 服务器时,我们想要确保我们不覆盖任何现有的文件。如果我们发现已经存在一个具有给定名称的文件,我们希望有一个函数可以以可预测的方式创建一个新的文件名——就像 Blender 在创建 Blender 对象名称时的行为。我们希望保留文件名的扩展名,所以我们不能简单地使用数字后缀。因此,nextfile()函数首先将文件名的路径名和扩展名部分分开。它使用os.path模块中的split()和splitext()函数来留下一个裸露的name。
如果名称已经以一个由点和一些数字组成的后缀结尾(例如,.42),我们希望增加这个数字。这正是下面那些令人敬畏的高亮行所完成的。Python 的 re 模块的 sub() 函数接受一个正则表达式作为第一个参数(我们在这里使用原始字符串,这样我们就不需要转义任何反斜杠了),并检查这个正则表达式是否与其第三个参数(在这种情况下是 name)匹配。这里使用的正则表达式(\.(\d+)$)匹配一个点后跟一个或多个十进制数字,前提是这些数字是最后一个字符。如果这个模式匹配,它将被 sub() 函数的第二个参数替换。在这种情况下,替换不是一个简单的字符串,而是一个 lambda(即未命名的)函数,它将被传递一个 match 对象,并期望返回一个字符串。
由于我们在正则表达式的数字部分周围添加了括号,我们可以通过调用 match 对象的 group() 方法来检索这些数字——不带前面的点。我们传递一个 1 作为参数,因为第一个开括号标记了第一个组(组 0 将是整个模式)。我们使用内置的 int() 函数将这个数字字符串转换为整数,然后加 1,并使用 str() 函数将其再次转换为字符串。在 lambda 函数自动返回这个结果之前,我们再次在前面添加一个点,以符合我们想要的模式。
我们通过检查生成的名称是否与原始名称不同来完成工作。如果它们相同,原始名称没有匹配我们的模式,我们只需将 .1 追加到名称上。最后,我们通过添加扩展名并调用 os.path 中的 join() 函数以平台无关的方式添加路径来重建完整的文件名:
def nextfile(filename):
(path,base) = os.path.split(filename)
(name,ext) = os.path.splitext(base)
new = re.sub(r'\.(\d+)$',lambda m:'.'+str(1+int(m.group(1))),name)
if new == name :
new = name + '.1'
return os.path.join(path,new+ext)
现在,我们已经准备好进行真正的上传文件到 FTP 服务器的操作。首先,我们通过调用 sethome() 函数确保我们的环境有一个合适的 HOME 变量。然后,我们检索我们想要上传到的 FTP 服务器的主机名(顺便说一句,输入 IP 地址而不是主机名是完全有效的):
if __name__ == "__main__":
sethome()
host = getftphost()
接下来,如果存在 .netrc 文件,我们将从该文件中检索所选主机的用户凭据(高亮显示)。这可能会因为各种原因失败(可能没有 .netrc 文件,或者给定的主机没有在这个文件中的条目);在这种情况下,将引发异常。如果发生这种情况,我们将通知用户,并通过合适的弹出窗口请求用户名和密码:
try:
(user,acct,password) = netrc.netrc().authenticators(host)
except:
acct=None
user = Draw.PupStrInput('No .netrc file found, enter username:',
"",75)
password = Draw.PupStrInput('Enter password:',"",75)
渲染后的图像将被存储为一个名为 Render Result 的 Blender Image 对象。接下来我们要做的是获取对这个图像的引用并确保它已存储在磁盘上。我们之前定义的 imagefilename() 函数将返回存储图像的文件名。
下一步是通过使用我们之前获取的主机名和凭据(已突出显示)连接到 FTP 服务器。一旦建立连接,我们就使用nlst()方法检索文件名列表:
im = Image.Get('Render Result')
filename = imagefilename(im)
ftp = FTP(host,user,password,acct)
files = ftp.nlst()
由于我们想确保不覆盖 FTP 服务器上的任何文件,我们使用basename()函数从存储的图像文件名中删除路径,并将结果与从服务器检索到的文件名列表进行比较(已突出显示)。如果文件名已经存在,我们使用nextfile()函数生成一个新的文件名,并再次检查,一直这样做,直到我们最终得到一个在 FTP 服务器上尚未使用的文件名。
dstfilename = os.path.basename(filename)
while dstfilename in files:
dstfilename = nextfile(dstfilename)
然后,我们通过调用storbinary()方法上传我们的图像文件。此方法将STOR前缀的文件名作为第一个参数,一个打开的文件描述符作为第二个参数。我们通过调用 Python 的内置open()函数,并将我们的图像文件名作为单个参数来提供后者。(有关ftplib模块相当奇特行为的更多详细信息,请参阅其docs.python.org/library/ftplib.html上的文档。)
ftp.storbinary('STOR '+dstfilename,open(filename))
ftp.quit()
Draw.PupMenu('Render result stored as "%s"%s|Ok'%(dstfilename,'%t'))
完整的代码作为ftp.py文件位于ftp.blend中。它可以从文本编辑器中运行,但在这个例子中,将ftp.py放入 Blender 的scripts目录中肯定更加方便。该脚本配置为在文件 | 导出菜单中提供自身。
春季大扫除——存档未使用的图像
经过一段时间,任何长期运行的项目都会积累很多无用之物。例如,尝试过但被丢弃以换取更好的纹理图像。此脚本将帮助我们通过找到所选目录中所有未被.blend文件引用的文件并将它们打包到ZIP 存档中来保持一定的秩序。
我们将注意不要将任何.blend文件移动到 ZIP 存档中(毕竟,我们通常希望能够渲染它们)以及 ZIP 存档本身(以防止无限递归)。我们将尝试删除我们存档的任何文件,如果删除文件留下一个空目录,我们也会删除该目录,除非它是.blend文件所在的目录。
文件操作函数由 Python 的os和os.path模块提供,并且可以使用zipfile模块在 Windows 和开放平台上操作 ZIP 文件。我们将把未使用的文件移动到的zipfile命名为Attic.zip:
import Blender
from os import walk,remove,rmdir,removedirs
import os.path
from zipfile import ZipFile
zipname = 'Attic.zip'
第一个挑战是生成一个列表,列出我们.blend文件所在的目录中的所有文件。listfiles()函数使用 Python 的os模块中的walk()函数递归地遍历目录树,并在过程中生成文件列表。
默认情况下,walk()函数以深度优先的方式遍历目录树,这允许我们动态地更改目录列表。这里使用此功能来删除任何以点开头的目录(高亮显示)。对于当前目录和父目录(分别由..和.表示)来说,这不是必要的,因为walk()已经过滤掉了它们,但这允许我们,例如,过滤掉我们可能遇到的任何.svn目录。
包含yield语句的行一次返回一个文件的结果,因此我们的函数可以用作迭代器。(有关迭代器的更多信息,请参阅docs.python.org/reference/simple_stmts.html#yield在线文档)我们将正确的文件名和路径连接起来,形成一个完整的文件名,并对其进行标准化(即,删除双路径分隔符等);尽管在这里进行标准化不是严格必要的,因为walk()预期会以标准化的形式返回任何路径:
def listfiles(dir):
for root,dirs,files in walk(dir):
for file in files:
if not file.startswith('.'):
yield os.path.normpath(os.path.join(root,file))
for d in dirs:
if d.startswith('.'):
dirs.remove(d)
在我们能够将.blend文件使用的文件列表与目录中现有的文件列表进行比较之前,我们确保任何打包的文件都解压到其原始文件位置。这虽然不是严格必要的,但确保我们不会将任何未直接使用但.blend文件中存在副本的文件移动到存档中:
def run():
Blender.UnpackAll(Blender.UnpackModes.USE_ORIGINAL)
Blender 模块中的GetPaths()函数生成一个由.blend文件使用的所有文件列表(除了.blend文件本身)。我们传递一个设置为True的绝对参数,以检索带有完整路径的文件名,而不是相对于当前目录的路径,以便正确地与listfiles()函数生成的列表进行比较。
再次,我们也对这些文件名进行了标准化。高亮行显示了如何通过传递当前 Bender 目录的缩写(//)到expandpath()函数来检索当前目录的绝对路径:
files = [os.path.normpath(f) for f inBlender.GetPaths(absolute=True)]
currentdir = Blender.sys.expandpath('//')
接下来,我们以写入模式创建一个ZipFile对象。这将截断任何具有相同名称的现有存档,并允许我们向存档中添加文件。存档的完整名称是通过连接当前 Blender 目录和我们想要使用的存档名称来构建的。使用os.path模块中的join()函数确保我们以平台无关的方式构建完整名称。我们将ZipFile对象的debug参数设置为3,以便在创建存档时将任何异常报告到控制台:
zip = ZipFile(os.path.join(currentdir,zipname),'w')
zip.debug = 3
removefiles 变量将在我们构建存档后记录我们想要删除的文件名。我们只能在创建存档后安全地删除文件和目录,否则我们可能会引用不再存在的目录。
存档是通过遍历当前 Blender 目录中所有文件的列表并比较它们与我们的 .blend 文件使用的文件列表来构建的。任何具有 .blend 或 .blend1 等扩展名的文件都会被跳过(突出显示),存档本身也是如此。文件是通过 write() 方法添加到 ZIP 文件的,该方法接受一个参数,即相对于存档(因此是当前目录)的文件名。这样,在新位置解压缩存档就更容易了。任何指向当前目录树外文件的引用都不会受 relpath() 函数的影响。我们添加到存档的任何文件都会通过将其添加到 removefiles 列表来标记为删除。最后,我们关闭存档——这是一个重要的步骤,因为省略它可能会导致我们得到一个损坏的存档:
removefiles = []
for f in listfiles(currentdir):
if not (f in files
or os.path.splitext(f)[1].startswith('.blend')
or os.path.basename(f) == zipname):
rf = os.path.relpath(f,currentdir)
zip.write(rf)
removefiles.append(f)
zip.close()
剩下的最后任务是删除我们移动到存档中的文件。Python 的 os 模块中的 remove() 函数将完成这项任务,但我们还希望删除在删除文件后变得空余的任何目录。因此,对于每个我们删除的文件,我们确定其目录的名称。我们还检查这个目录是否不指向当前目录,因为我们想确保我们不会删除它,因为这是我们的 .blend 文件所在的位置。尽管这种情况不太可能发生,但在 Blender 中打开 .blend 文件并删除该 .blend 文件本身可能会留下一个空目录。如果我们删除这个目录,任何随后的(自动)保存都会失败。relpath() 函数如果其第一个参数指向的目录与第二个参数指向的目录相同,将返回一个点。(samefile() 函数更健壮且直接,但在 Windows 上不可用。)
如果我们确定我们没有引用当前目录,我们使用 removedirs() 函数来删除目录。如果目录不为空,这将失败并抛出 OSError 异常(即我们删除的文件不是目录中的最后一个文件),我们忽略这个异常。removedirs() 函数还会删除通向目录的所有父目录,如果它们为空,这正是我们想要的:
for f in removefiles:
remove(f)
d = os.path.dirname(f)
if os.path.relpath(d,currentdir) != '.':
try:
removedirs(d)
except OSError:
pass
if __name__ == '__main__':
run()
完整的代码作为 zip.py 文件存放在 attic.blend 中。
扩展编辑器——使用正则表达式进行搜索
编辑器已经提供了基本的搜索和替换功能,但如果你习惯了其他编辑器,你可能会错过使用正则表达式进行搜索的可能性。此插件提供了这一功能。
正则表达式非常强大,许多程序员喜欢它们的灵活性(而许多人则讨厌它们糟糕的可读性)。无论你爱它还是恨它,它们都非常具有表现力:匹配任何十进制数字可以简单地表示为 \d+(例如,一个或多个数字)。如果你在寻找一个在英式英语或美式英语中拼写不同的单词,例如 colour/color,你可以使用表达式 colou?r(带有可选的 u)来匹配任何一个。
以下代码将展示 Blender 内置的编辑器只需几行代码就可以配备这个有用的搜索工具。提供的脚本应安装在 Blender 的 scripts 目录中,然后可以从文本编辑器菜单作为 文本 | 文本插件 | 正则表达式搜索 或通过快捷键 Alt + Ctrl + R 调用。它将弹出一个小的输入小部件,用户可以在其中输入正则表达式(此弹出窗口将记住最后输入的正则表达式),如果用户点击 确定 按钮或按 Enter 键,光标将定位到第一个匹配正则表达式的位置,并突出显示匹配的范围。

要将脚本注册为具有指定快捷键的文本插件,脚本的前几行包含常规标题,并增加了一个 Shortcut: 条目(如下所示突出显示):
#!BPY
"""
Name: 'Regular Expression Search'
Blender: 249
Group: 'TextPlugin'
Shortcut: 'Ctrl+Alt+R'
Tooltip: 'Find text matching a regular expression'
"""
下一步是导入必要的模块。Python 为我们提供了一个标准的 re 模块,该模块有很好的文档(即使是对于不熟悉正则表达式的初学者,在线文档也足够了),我们导入 Blender 的 bpy 模块。在这本书中,我们很少使用这个模块,因为它被标记为实验性的,但在这个例子中,我们需要它来找出哪个文本缓冲区是活动的:
from Blender import Draw,Text,Registry
import bpy
import re
为了指示任何错误条件,例如非法的正则表达式或没有匹配项,我们定义了一个简单的 popup() 函数:
def popup(msg):
Draw.PupMenu(msg+'%t|Ok')
return
因为我们想记住用户输入的最后一个正则表达式,所以我们将使用 Blender 的注册表,因此我们定义了一个键来使用:
keyname = 'regex'
run() 函数将所有功能结合起来;它检索活动文本缓冲区,如果没有活动缓冲区则退出:
def run():
txt = bpy.data.texts.active
if not txt: return
随后,它检索此缓冲区内的光标位置:
row,col = txt.getCursorPos()
在向用户提供弹出窗口以输入正则表达式之前,我们检查是否在注册表中存储了一个之前的正则表达式。我们简单地检索它,如果失败,则将默认表达式设置为空字符串(突出显示)。请注意,我们不会向 GetKey() 函数传递任何额外的参数,因为我们不希望在磁盘上存储任何信息。如果用户输入一个空字符串,我们只需返回而不进行搜索:
d=Registry.GetKey(keyname)
try:
default = d['regex']
except:
default = ''
pattern = Draw.PupStrInput('Regex: ',default,40)
if pattern == None or len(pattern) == 0 : return
我们编译正则表达式以查看其是否有效,如果失败,则显示一条消息并返回:
try:
po = re.compile(pattern)
except:
popup('Illegal expression')
return
现在我们知道正则表达式是正确的,我们从光标所在的行(高亮显示)开始遍历文本缓冲区的所有行。对于每一行,我们将编译的正则表达式与字符串(或如果它是第一行,则是光标后的部分)进行匹配。
first = True
for string in txt.asLines(row):
if first :
string = string[col:]
mo = re.search(po,string)
如果有匹配项,我们会在行内记录匹配的开始位置和匹配的长度(如果是第一行,则适当分隔),并将光标位置设置为当前行和匹配的开始位置(高亮显示)。我们还设置“选择位置”为匹配位置加上匹配长度,以便我们的匹配项被高亮显示并返回。如果行内没有匹配项,我们增加行索引并继续迭代。
如果没有更多可迭代的项,我们向用户发出信号,表示我们没有找到任何匹配项。在所有情况下,我们将正则表达式存储在注册表中以供重用:
if mo != None :
i = mo.start()
l = mo.end()-i
if first :
i += col
txt.setCursorPos(row,i)
txt.setSelectPos(row,i+l)
break
row += 1
first = False
else :
popup('No match')
Registry.SetKey(keyname,{'regex':pattern})
if __name__ == '__main__':
run()
完整的代码作为 regex.py 存放在 regex.blend 中,但应该安装到 Blender 的 scripts 目录中,并使用合适的名称,例如 textplugin_regex.py。
扩展编辑器——与 Subversion 交互
当积极开发脚本时,跟踪更改或回滚到先前版本可能会很困难。这不仅仅是在 Blender 中编写 Python 脚本时的问题,而且多年来已经发展了许多 版本控制系统。其中之一是广为人知且广泛使用的 Subversion (subversion.tigris.org)。在本节中,我们展示了如何增强编辑器以提交或更新来自仓库的文本文件。
与 Subversion 仓库的交互不是由捆绑的 Python 模块提供的,因此我们必须从其他地方获取它。在 pysvn.tigris.org 的 下载 部分包含了多个平台的源代码和二进制分发。请确保获取正确的版本,因为支持的 Subversion 版本和 Python 版本可能不同。我们在这里开发的脚本针对 Subversion 1.6.x 和 Python 2.6.x 进行了测试,但应该也能与更早版本的 Subversion 一起工作。
我们将实现将文本文件提交到仓库以及更新文件(即从仓库获取最新修订版)的功能。如果我们尝试提交尚未成为仓库一部分的文件,我们会将其添加,但我们将不会实现创建仓库或检出工作副本的工具。例如,Windows 上的 TortoiseSVN (tortoisesvn.tigris.org/) 或任何数量的开放平台工具都更适合这项工作。我们假设有一个检出的工作目录,我们将 Blender 文本文件存储在这里。(这个工作目录可能与你的 Blender 项目目录完全不同。)
将文件提交到仓库
将文本缓冲区提交到仓库是一个两步过程。首先,我们必须将文本缓冲区的内容保存到文件中,然后我们将这个文件提交到仓库。我们必须检查文本块是否有关联的文件名,如果没有,则提示用户先保存文件。用户必须将文件保存到已签出的目录中,以便将文件提交到仓库。
就像允许我们使用正则表达式进行搜索的扩展一样,这个扩展以一个合适的标题开始,以标识它作为一个文本编辑插件,并分配一个键盘快捷键。我们为提交(高亮显示)定义了 Ctrl + Alt + C 作为快捷键,正如我们将在其伴随脚本中为更新定义 Ctrl + Alt + U 一样。我们还导入了必要的模块,特别是 pysvn 模块:
#!BPY
"""
Name: 'SVNCommit'
Blender: 249
Group: 'TextPlugin'
Shortcut: 'Ctrl+Alt+C'
Tooltip: 'Commit current textbuffer to svn'
"""
from Blender import Draw,Text,Registry
import bpy
import pysvn
def popup(msg):
Draw.PupMenu(msg+'%t|Ok')
return
run() 函数首先尝试获取活动文本缓冲区,如果没有找到,则不会抛出异常并返回。然后它检查是否为这个文本缓冲区定义了文件名(高亮显示)。如果没有,它会提醒用户先保存文件(从而定义一个文件名并将文件放置在已签出的目录中)然后返回。
def run():
txt = bpy.data.texts.active
if not txt: return
fn = txt.getFilename()
if fn == None or len(fn) == 0:
popup('No filename defined: save it first')
return
下一步是创建一个 pysvn 客户端对象,这将使我们能够与仓库交互。它的 info() 方法允许我们检索有关文件仓库状态的详细信息(高亮显示)。如果没有信息,则表示该文件尚未添加到仓库中——这种情况我们可以通过调用 add() 方法来纠正:
svn = pysvn.Client()
info = svn.info(fn)
if info == None:
popup('not yet added to repository, will do that now')
svn.add(fn)
接下来,我们将文本缓冲区的当前内容写出来,通过将其中所有行连接成一个单一的数据块,并将其写入我们为与缓冲区关联的文件名打开的文件对象中:
file=open(fn,'wb')
file.write('\n'.join(txt.asLines()))
file.close()
这个文件将通过 checkin() 方法提交到仓库,我们传递一个相当不具信息量的提交信息。可能提示用户输入一个更合理的消息是个好主意。最后,我们通知用户结果版本号。
注意
注意,Subversion 版本号不是与文件相关联,而是与仓库相关联,因此如果在此期间其他文件已被提交,这个数字可能比上一个文件提交的数字多一个以上。
version = svn.checkin(fn,'Blender commit')
popup('updated to rev. '+str(version))
if __name__ == '__main__':
run()
完整代码作为 textplugin_commit 在 svn.blend 中可用,但应安装在 Blender 的 scripts 目录中。
从仓库更新文件
仓库的全部目的在于能够协作,这意味着其他人也可能更改我们正在工作的文件,我们必须能够检索这些提交的更改。这被称为更新文件,意味着我们将存储在仓库中的最新版本复制到我们的工作目录中。
除了检查文本缓冲区是否已保存以及文件是否已添加到版本库中,我们还必须检查我们的当前版本是否比版本库中的版本更新或已更改。如果是这样,我们向用户提供选择,是放弃这些更改并恢复到版本库中的版本,还是提交文本缓冲区中的版本。(这里没有提供合并差异的第三个选项;尽管 Subversion 当然能够做到这一点,至少对于文本文件来说是这样,但最好留给更通用的工具,如 TortoiseSVN。)
脚本的第一部分与提交脚本非常相似。主要区别是不同的快捷键:
#!BPY
"""
Name: 'SVNUpdate'
Blender: 249
Group: 'TextPlugin'
Shortcut: 'Ctrl+Alt+U'
Tooltip: 'Update current textbuffer from svn'
"""
from Blender import Draw,Text,Registry
import bpy
import re
import pysvn
def popup(msg):
Draw.PupMenu(msg+'%t|Ok')
return
run()函数也以类似的方式开始,它检索活动文本缓冲区(如果有)并检查文本缓冲区是否有相关文件名(突出显示)。它还检查文件名是否已添加到版本库中,如果没有,则通过调用add()方法进行纠正,并通过弹出窗口通知用户:
def run():
txt = bpy.data.texts.active
if not txt: return
fn = txt.getFilename()
if fn == None or len(fn) == 0:
popup('No filename defined: save it first')
return
svn = pysvn.Client()
info = svn.info(fn)
if info == None:
popup('not yet added to repository, will do that now')
svn.add(fn)
在将文本缓冲区的内容写入其相关文件后,它调用status()方法来查看我们写入的文件(因此是文本缓冲区的内容)与版本库中的版本相比是否已修改(突出显示)。status()方法也可以传递一个列表的文件名,并且总是返回一个结果列表,即使我们只传递一个文件名——因此有[0]索引。如果我们的文本缓冲区已修改,我们通知用户并提供选择:要么放弃更改并检索存储在版本库中的版本,要么提交当前版本。还可能通过点击菜单外取消整个操作,在这种情况下PupMenu()将返回-1:
file=open(fn,'wb')
file.write('\n'.join(txt.asLines()))
file.close()
if svn.status(fn)[0].text_status == pysvn.wc_status_kind.modified:
c=Draw.PupMenu('file probably newer than version in'+
'repository%t|Commit|Discard changes')
if c==1:
svn.checkin(fn,'Blender')
return
elif c==2:
svn.revert(fn)
从版本库检索版本后,我们刷新文本缓冲区的内容:
txt.clear()
file=open(fn)
txt.write(file.read())
file.close()
最后,我们通过再次调用status()方法并获取commit_revision字段来通知用户文本缓冲区内容的修订号:
popup('updated to rev. '+str(svn.status(fn)[0].entry.commit_revision))
if __name__ == '__main__':
run()
完整代码作为textplugin_svnupdate在svn.blend中可用,并且像其提交对应版本一样,应该安装在 Blender 的scripts目录中。
与版本库一起工作
尽管关于使用 Subversion 的完整教程超出了本书的范围,但为 Blender 项目中的脚本版本控制流程勾勒一个轮廓可能是有用的。
重要的是要理解,Blender 项目本身不必置于版本控制之下。我们可以以任何有意义的任何方式组织我们的 Blender 项目,并在其中有一个受版本控制的scripts目录。
假设我们在网络存储设备上创建了一个脚本版本库,并在我们的本地机器上创建了一个 Blender 项目目录。为了将我们的脚本置于版本控制之下,我们必须执行以下步骤:
-
在我们的 Blender 项目目录内检出脚本版本库(这被称为版本库的工作副本)。
-
使用内置编辑器在我们的
.blend文件中创建一个脚本。 -
将此脚本保存到工作副本中。
-
每次我们更改任何内容时,我们都按下 Ctrl + Alt + C 来提交我们的更改。
-
每次我们再次开始编写脚本时,首先按下 Ctrl + Alt + U 来查看是否有人更改了任何内容。
注意,我们并不反对将所有资产,如纹理或作为版本控制库使用的 .blend 文件等全部带入,但我们必须使用单独的客户端来提交更改。创建一些提交或更新当前 Blender 目录中所有文件的脚本将是一项有趣的练习。
对速度的需求——使用 Psyco
Python 是一种解释型语言:脚本中的所有指令都会在遇到时被解释并重新执行。这听起来可能效率不高,但对于程序的开发者来说,能够快速开发和测试程序的优势可能超过了程序运行较慢的劣势。而且,解释可能效率不高,但这并不等同于慢。Python 是一种非常高级的语言,所以单个语言元素可能相当于许多低级指令。此外,考虑到现代硬件,即使是慢速的脚本也可能比用户预期的结果更快地完成。
然而,在任何情况下,任何速度的提升都是受欢迎的。从本书中我们看到的所有示例来看,Pynodes 可能是计算量最大的,因为指令会在纹理或着色器中的每个可见像素上运行,并且如果考虑超采样,每个像素可能还会运行许多更多次。从执行时间不到一秒的脚本中节省几毫秒并不算什么,但节省 20%的渲染时间,在渲染 500 帧的镜头时,可以节省相当多的时间。
进入 Psyco:Psyco 是一个 Python 扩展,它试图通过将脚本中频繁使用的部分编译成机器指令并存储起来以供重用,来加速脚本的执行。这个过程通常被称为 即时编译,类似于其他语言(如 Java)中的即时编译。(在概念上相似,但在实现上由于 Python 的动态类型而相当不同。这对于 Python 脚本的开发者来说并不重要。)重要的是,Psyco 可以在任何脚本中使用,而无需对代码进行任何更改,只需添加几行即可。
Psyco 作为 Windows 的二进制包可用,并且可以在其他平台上从源代码编译。完整说明可在 Psyco 网站上找到:psyco.sourceforge.net/。
请确保您安装的版本与您的 Python 安装兼容,因为尽管网站声明为 Python 2.5 编译的版本也应该适用于 2.6,但它仍然可能会失败,所以最好使用专门为 2.6 编译的版本。现在,我们可能期望的速度提升是多少?这很难估计,但很容易测量!只需渲染一帧,并注意它花费的时间,然后导入代码中的 psyco,再次渲染,并注意差异。如果差异显著,请保留代码,否则您可能再次删除它。
在以下表格中,列出了pysco.blend提供的测试场景的一些结果,但您的实际效果可能会有所不同。请注意,测试场景是一个相当乐观的情景,因为大多数渲染都被一个由 Pynode 生成的纹理覆盖。如果这个覆盖更少,速度的提升也会更少,但这确实给出了使用 Psyco 可能实现的效果。相关代码的速度提升可以达到两倍。以下表格列出了一些说明性的样本时间:
| 时间(秒) | 没有 Psyco | 有 Psyco |
|---|---|---|
| 净书本 | 52.7 | 26.3 |
| 桌面 | 14.01 | 6.98 |
启用 Psyco
以下代码显示了在之前遇到的raindrops Pynode 上启用 Psyco 所需的附加行。变更以粗体表示。
<... all other code unchanged ...>
__node__ = Raindrops
try:
import psyco
psyco.bind(Raindrops.__call__)
print 'Psyco configured'
except ImportError:
print 'Psycho not configured, continuing'
pass
所以基本上,在 Pynode 的定义之后只添加了几行。确保在 Pynode 上点击更新按钮,否则代码将不会被重新编译,更改将不会可见。
之前的代码只是尝试导入psyco模块。如果导入失败(任何原因),控制台将打印出一条信息性消息,但代码仍然可以正常运行。如果成功导入,我们指导 Psyco 通过调用bind()函数并传递一个指向此__call__方法的引用来优化__call__()方法,并在控制台上通知用户我们已成功配置 Psyco。
摘要
在本章中,我们不仅探讨了 3D 和渲染,还展示了如何通过提供一些脚本帮助处理一些常见的维护任务,通过扩展内置编辑器的正则表达式搜索和版本控制功能,以及如何通过使用 Psyco 在某些情况下节省宝贵的渲染时间。具体来说,我们学习了:
-
如何列出和存档资产,如图像映射
-
如何使用 FTP 自动发布渲染图像
-
如何使用正则表达式搜索扩展内置编辑器的功能
-
如何使用即时编译器 Psyco 加速计算
-
如何使用 Subversion 给你的脚本添加版本控制
附录 A. 链接和资源
互联网上有许多优秀的资源,可以为您提供几乎任何您能想到的 Blender 主题的额外信息。以下不是详尽的列表,但希望为您提供一些好的起点。在这里提出一些建议可能很合适,尤其是对于新手:利用互联网的优势,在论坛上提问之前先做一些研究。Blender 用户(和开发者)非常友好和乐于助人,但在许多论坛上,简单的问题可能无人回答,因为人们觉得答案可以在网上找到,或者通过在论坛存档中搜索就能找到,不值得花时间去回答。这有时可能会令人失望,但许多这种失望可能通过事先做一些研究来避免。
有关 Blender 的通用论坛和博客
Blender 的主页是重要的信息来源,以下页面应被视为必读:
-
www.blender.org: 提供 Blender 发展新闻的主页。
-
wiki.blender.org: 包含手册、教程和资源链接的维基。
一些值得注意的与 Blender 相关的通用论坛,因为它们吸引了 Blender 社区的大部分注意力:
-
www.blenderartists.org: 该网站托管了许多论坛。对于本书的读者来说,特别相关的是 Python 和插件论坛,新手和经验丰富的 Blender 黑客在这里都能找到一群乐于助人的人来帮助他们解决脚本相关的问题。本书的作者也在这里,以他的“varkenvarken”昵称出现。
-
www.blendernation.com: 该网站试图成为所有 Blender 相关新闻的中心枢纽,并且在这方面做得相当成功。它的 RSS 订阅源是您浏览器导航栏中的一个有用补充,可以帮助您保持最新信息。
Python 编程
本节列出了一些与 Python 相关的通用资源。Blender 脚本资源将在下一节中列出。
-
www.python.org: 这是主要网站,组织得非常好,尽管这里可以单独提及一些主要部分。
-
www.python.org/download/: 如果您还没有安装或者安装的版本与 Blender 内置版本不完全匹配,可以在这里下载完整的 Python 发行版。
对于新人和有经验的程序员来说,以下页面提供了一些关于 Python 的通用教程和一些特定主题的“如何做”指南。这些页面上的所有文章都有相当低的入门曲线:
-
docs.python.org/tutorial: 对于在其他编程语言中经验丰富并想学习 Python 的人来说,特别值得一读。它涵盖了大多数与 Python 相关的问题,对于大多数人来说,应该足以开始学习 Python。
-
docs.python.org/howto: 详细介绍了正则表达式、网络编程和 Python 风格等主题。
关于 Python 编程语言及其捆绑模块的更详细信息也都可以找到。这些页面是它们的作者和维护者的荣誉,因为尽管它们的覆盖范围和深度非常全面,但它们仍然非常易于阅读。
-
docs.python.org/reference/: 这是关于所有语言结构(如语句、数据类型和异常)的主要参考。
-
docs.python.org/library/: 所有捆绑模块、内置函数和对象的终极参考。在你甚至考虑自己编程之前,你应该查看这个页面,看看是否有模块已经提供了满足你需求的功能。
-
pypi.python.org/pypi: 如果捆绑模块没有提供你所需要的功能,那么很可能有第三方遇到了相同的问题并编写了一个包来处理这个问题。如果是这样,你可能会在 Python 的包索引中找到它。
-
code.activestate.com/recipes/langs/python/: 有时候,你只需要一个代码片段、代码示例或一个算法来开始。你可能会在这里找到它。
Blender 脚本编程
关于 Blender 脚本的具体信息也容易获得。
-
wiki.blender.org/index.php/Doc:Manual/Extensions/Python: 提供了如何在不同平台上使用 Python 与 Blender 进行安装和配置的重要信息,并包括内置编辑器的相关信息。 -
www.blender.org/documentation/249PythonDoc/: Blender Python API 的官方文档。当我们在这本书中提到“API 文档”时,我们指的是这个页面。在开始脚本编写之前,至少从头到尾阅读两遍。这将为你提供一个关于可能性的优秀概述,并且当你需要信息时,阅读完整的内容会使查找信息变得更加容易。 -
wiki.blender.org/index.php/Extensions:Py/Scripts: 包含了众多 Blender 脚本,包括捆绑的脚本和人们编写的额外脚本。在尝试自己发明新东西之前,检查一下是否有人已经为你发明了它。
-
www.opengl.org/sdk/docs/man/: Blender 的 Python API 也提供了许多 OpenGL 函数,可以直接在屏幕上绘制事物。API 文档引用了这些页面以提供详细信息。OpenGL 不是轻而易举就能掌握的主题,所以如果你想要做得更复杂,你可能需要先查看一些教程。
www.opengl.org/code/列出了一些可能有助于开始的条目。
本书使用的外部包
本书中的大多数示例不需要任何外部 Python 包即可完成任务,因为我们所需的大部分功能已经由 Python 的标准模块或 Blender API 提供。然而,对于一些特定领域,我们使用了免费提供的第三方包。
-
www.pythonware.com/products/pil/index.htm: Python 图像库(PIL)包含了所有可想象的 2D 功能,因此是 Blender API 的优秀补充,因为 Blender 的 API 在这个领域相对缺乏。
-
psyco.sourceforge.net: Psyco 是一个针对 Python 的即时编译器,它可能在不修改您代码的情况下,为计算密集型的 Python 代码(如 Pynodes)提供显著的性能提升。
-
subversion.tigris.org: Subversion (SVN) 是一个广泛使用的版本控制软件包(也被 Blender 基金会用于 Blender 本身的开发)。Python 特定的绑定可以在单独的页面找到
pysvn.tigris.org/。如果您需要一个与 Windows 文件资源管理器集成的 SVN 客户端,TortoiseSVN 可能值得一试(有意为之):tortoisesvn.tigris.org/。Subversion 的主要页面列出了许多针对开放平台的 SVN 客户端链接。
其他信息来源
在研究如何建模或动画化某些事物时,查看现实生活中的例子是至关重要的。您可以在网上轻松找到图片,但简单的图片通常不足以深入了解基本原理或内部工作方式。对于这些,维基百科是一个非常有用的资源,本书中许多内容都进行了咨询。以下是一些样本。
虽然如此,也许应该提醒一下。多年来,维基百科已经证明了自己是一个易于访问和可靠的信源,其质量似乎与印刷百科全书相当。然而,任何人都可以更改这些信息,并且可能存在对该主题的不同观点,因此,检查其他资源总是明智的,尤其是如果这些是难以理解的主题。好的维基百科条目通常会列出外部参考,检查其中的一些是个好主意。
这些是在创建本书中的示例时查阅的一些页面。其中一些更偏向数学的页面在此列出(它们在正文文本中也有提及):
当然,生活中不仅仅是维基百科,以下两个网站在实现彩虹渐变着色器时特别有用:
最后,这些网站值得特别提及:
-
Blender 材质网站: 目前托管在
matrep.parastudios.de/,该网站提供了一个庞大的 Blender 材质数据库,这些材质可以直接使用,或者可以作为您自己材质的起点。Blender 材质可能值得一本自己的书,但这个网站提供了来自社区生成的材质的丰富选择,这可以让您在项目中领先一步。 -
Blender 地下城: 这里被列为一个优秀的教程来源。
附录 B. 常见陷阱
阅读 API 文档
虽然听起来有些繁琐,但说许多在 Blender 论坛上提出的与 Python 相关的问题可以通过仔细阅读 API 文档来简单地回答,这并不夸张。
当然,这些文档不是最容易阅读的,因为它们涵盖了众多主题,而且往往不清楚从哪里开始阅读,所以至少从头到尾完整阅读一次可能是个好主意。这不仅会给你一些关于 Blender API 广阔范围的想法,而且还会帮助你以后找到特定的主题。
导入的问题
经常出现的问题之一是为什么 import 语句没有按预期工作。这里的问题是,你必须知道预期的结果。Blender 扩展了 Python 的标准导入行为,使其能够从 .blend 文件中驻留的文本文件中导入。这是一个巨大的便利,因为它允许你模块化你的代码,而无需分发单独的文件。然而,导入这些内部文件的部分的行为应该非常清晰,以避免让你遇到令人不快的惊喜,但目前这部分并不是非常详细地记录在文档中。
当执行如 import foo 这样的 import 语句时,会发生以下情况:
-
检查
foo.pyc或foo.py是否存在于sys.path中的任何目录中 -
如果其中之一存在:
-
如果
foo.py更新了- 编译
foo.pyfoo.pyc
- 编译
-
使用
foo.pyc
-
-
否则,如果
foo.py作为内部文本文件存在:-
如果它尚未编译:
- 编译内部文本文件
-
使用编译版本
-
-
否则
- 抛出异常
第一部分是 Python 的常规行为(这里简化了一些,因为我们没有在这里提及包或 .pyo 文件),第二部分是如果所需的模块不是外部文件时,Blender 添加到其中的内容。这里有两个重要的事项需要注意:如果存在与内部文件同名的外部文件,外部文件(或其编译版本)将具有优先权。这可能会让人烦恼,因为许多人保存了与内部文件同名的外部副本。如果这两个文件不同步,可能会发生意外的事情。幸运的是,Blender 的内部编辑器通过在内部文件名称旁边显示一个 Out of Sync(不同步)按钮来提醒你这种情况。尽管如此,如果你没有打开该特定文件的文本编辑器,你可能不会注意到它。
此外,如果你仔细查看之前的概述,你会注意到如果 Blender 正在寻找一个内部文件,它会检查这个内部文件是否已经编译,但不会检查源文件是否可能更新。这意味着任何要导入的内部源代码更改都不会被主程序看到。为了解决这个问题,你可以通过使用内置的 reload() 函数强制 Python 编译模块。这在运行程序时效率较低,但在开发过程中可以节省很多麻烦。一旦你的脚本准备就绪,你可能会放弃使用 reload()。
假设你有两个内部文件,main.py 和 mymodule.py,并且你想要确保在 main.py 执行后,module.py 的更改始终可见,那么每个文件可能看起来像这样:
# main.py
import mymodule
reload(mymodule)
mymodule.myfunction()
# mymodule.py
def myfunction():
print "myfunction called"
突出的行显示了至关重要的 reload() 函数。
安装完整的 Python 发行版
两个具体问题经常出现:要么是一个标准的 Python 模块似乎丢失了(一个 import 语句会引发异常,告诉你它找不到请求的模块),要么 Blender 警告说它找不到与编译版本相等的 Python 发行版。
这两个问题都在 第一章 使用 Python 扩展 Blender 中得到了解决,并且对于更多信息请参考:wiki.blender.org/index.php/Doc:Manual/Introduction/Installing_Blender/Python。
附录 C.未来开发
Blender
Blender 是一个稳定且具有生产质量的软件包,但它自从开源以来一直在进行大量开发。几乎每个版本的 Blender 都带来了新功能——有些很小,有些确实非常复杂。Blender 的开发变化在 Blender 网站上得到了很好的记录:www.blender.org/development/release-logs/。
Blender 当前的稳定版本是 2.49,这个版本可能会持续一段时间,因为 Blender 不再仅仅是 3D 爱好者使用的开源软件包,而是一个在专业工作室的生产流程中使用的可行生产工具。
然而,截至撰写本书时,Blender 新版本的开发正在全面进行。版本号 2.50 可能会让你认为这只是一个小改动,但实际上,它几乎是一个完全的重写。最明显的变化是完全不同的图形用户界面。这个界面几乎完全是用 Python 编写的,这为编写复杂的用户界面提供了无限的机会,以取代 2.49 中的有限可能性。
不仅用户界面发生了变化,内部结构也进行了彻底的改造,尽管在 Python API 中暴露的功能在本质上保持相似,但 Blender 的大多数模块都发生了许多变化。
一个主要的缺点是,新版本是在 Durian 项目(该项目将制作开源电影"Sintel",见durian.blender.org)的同时开发的,因此 2.50 版本的主要开发目标是提供该项目所需的所有功能。这确实涵盖了大多数问题,但一些部分,特别是 Pynodes 和屏幕处理器,在首次生产版本中不会提供。
从积极的一面来看,将不再需要在 Blender 旁边安装完整的 Python 发行版,因为新版本将捆绑完整的 Python 发行版。
Blender 2.50 版本的开发路线图可以在www.blender.org/development/release-logs/blender-250/找到,但当然,那里提到的日程安排是非常初步的。完整的生产版本预计将在 2010 年底推出,并将具有 2.6 的版本号。
Python
新版本的 Blender 将捆绑完整的 Python 3.x 发行版,从而消除了单独安装 Python 的需求。这个版本的 Python 已经非常稳定,未来不太可能实施重大更改。
3.x 版本与 2.6 版本不同,但最显著的变化都在表面之下,大多数不会影响到 Blender 脚本编写者。
尽管新版本的 Python 有一个显著的副作用:许多第三方包(即,未与发行版捆绑的 Python 包)尚未移植到 3.x 版本。在某些情况下,这可能会造成相当大的不便。从本书使用的包中,尚未移植到 3.x 版本的最显著的包是 PIL(Python 图像库)。这个包确实很受欢迎,因为它提供了 Blender 中不存在的复杂 2D 功能。
另一个尚未移植到 3.x 的包是 Psyco——即时编译器,但 Python 3 在许多情况下已经相当快了,因此像 Psyco 这样的包所能达到的速度提升可能不值得麻烦。
为了加快 Python 3.x 的接受速度,Python 的开发者宣布了对新特性的添加实行禁令,这样包的开发者就不必针对一个移动的目标进行瞄准。关于这个主题的更多信息可以在www.python.org/dev/peps/pep-3003/找到。








浙公网安备 33010602011771号