当 Python 邂逅 POV-Ray

当 Python 邂逅 POV-Ray

引言

POV-Ray 是一种专业的三维场景描述语言,它描述的三维场景可交由 POV-Ray 的解析器(或编译器)采用光线跟踪技术进行渲染,渲染结果为位图。

POV-Ray 语言是图灵完备的,亦即其他编程语言能写出来的程序,使用 POV-Ray 语言总能等价地写出来。不过,这个世界上不会有人打算使用 POV-Ray 语言来编写网络服务程序、GUI 程序以及那些运行在手机上的 APP,更何况也写不出来。两个程序等价,是数学意义上的,而不是物理意义上的。许多时候,我们是在编写一些非图形渲染类的程序时,需要绘制一些三维图形,这时就可以考虑如何用自己最熟悉的编程语言去驱动 POV-Ray 这支画笔,即为 POV-Ray 编写代码生成器。

本文介绍了使用 Python 3 为 POV-Ray 编写代码生成器的基本思路。所实现的代码生成器重视 POV-Ray 的建模功能,而忽视其光线追踪渲染功能。凡涉及渲染之处,仅仅使用 POV-Ray 的一些非常简单的渲染语句,这种处理颇类似于绘画艺术中的白描。因为我之所以有动力写这份文档,是因为我要使用 POV-Ray 来绘制我的一些论文与演示文档里的插图。这些插图以表意为主,基本不需要考虑如何让观阅它们的人信以为真。之所以选择 Python 语言来驱动 POV-Ray,是因为它便于我在写文档的过程中可以忽略许多数据结构与内存管理上的细节。实际上,在写这份文档之前,我一直在用 C 语言生成 POV-Ray 代码。

我不会对 POV-Ray 与 Python 语言本身作太多介绍,因为我对它们也仅仅是略知一二。我在附录中给出了我曾经粗略翻过的 POV-Ray 与 Python 的一些文档的链接。

模型、视图与控制器

在计算机中绘制图形,无论是二维还是三维,无论是古代还是现代,一直存在着一个基本范式,即:模型-视图-控制器。在使用 Python 驱动 POV-Ray 的过程中,这个范式依然有效。

直接使用 POV-Ray 语言,可以像下面这样描述这个范式:

@ model.inc # [POV-Ray]
... 定义一些模型 ...
@
@ view.inc # [POV-Ray]
... 设置光源与相机  ...

#include "model.inc" // 加载模型

... 绘制模型 ...
@
@ controller.ini # [POV-Ray]
... 设置动画参数 ...
@

注:上述诸如 @ model.inc # [POV-Ray] 之类的语句,可理解为注释。位于符号 @# 之间的文本是文件名,位于 # 符号之后的 [...] 中的文本表示其后面的代码段所用的语言。每个代码段的尾部都有一个 @ 符号,表示代码段至此终止。这种注解形式来自我写的一个文式编程工具所支持的标记,详见 https://github.com/liyanrui/orez

POV-Ray 语言并没有刻意强调模型-视图-控制器范式,但其语法能够很自然地描述这种范式。模型注重的是几何形体,视图注重的是如何观察几何形体,而控制器用于控制视角的变化。POV-Ray 虽然只能给出位图形式的渲染结果,但由于它提供了定时器功能,利用这一功能可生成一组视角有序变化的渲染结果,然后将它们组合为 GIF 格式的动图,我将这种方式称为 POV-Ray 视图的控制器。

其实,这种范式无处不在。与其说是哪个人发明了它,不如说这是人类处理复杂任务时的本能反应。简单举个餐饮业的例子,农民为餐饮业提供了模型,厨师为餐饮业创造了视图,食客为餐饮业创造了控制器。用经济学的术语来说,就是「生产的社会化」,强调的是有规模的分工与合作。

模型

下面从最简单的任务开始,以点与点集的绘制为例,讲述如何用 Python 实现 POV-Ray 模型。

POV-Ray 语言没有提供点对象的绘制语法,但是可以使用直径很小的球体来表示点对象:

sphere {
  <x, y, z>, r
}

<x, y, z> 为球心,r 为半径。

基于这一发现,就可以构造点集模型:

#declare points = union {
  sphere {<x1, y1, z1>, r}
  sphere {<x2, y2, z2>, r}
  ... ... ...
}

#declare 是 POV-Ray 提供的用于定义局部变量的指令。points 是变量的名字。union 是 POV-Ray 的三维实体模型的布尔运算符,它可将一组三维实体合并为一个对象。上述代码中,所有小球的半径相同。

现在开始考虑,如何使用 Python 语言自动生成上述的点集模型的 POV-Ray 描述。假设有一份名为 points.asc 的文本文件,其中的每一行文本存储一个三维点的坐标值,例如:

2.3 4.5 1.1
3.0 8.7 11.3
... ... ...

若使用 Python 语言写一个程序,让它来解析 points.asc 文件,然后再基于解析到的点集数据生成 POV-Ray 模型文件,这样就可以避免手动去写一大堆 sphere 语句。更重要的是,多数情况下,像 points.asc 这样的文件是现有的,例如一些程序输出的数据与三维扫描设备从实物表面上采集到的数据等等。

下面是一份从 points.asc 这样的文件解析三维点集数据的 Python 代码:

@ points-to-pov.py # [Python]
def points_parser(src_file_name, right_handed = True, dim = 3):
    points = []
    with open(src_file_name, 'r') as src:
        for line in src:
            coords = line.split()
            if len(coords) != dim:
                continue
            x = []
            for t in coords:
                x.append(float(t))
            if right_handed: 
                x[dim - 1] = -x[dim - 1] # 将 x 的坐标从右手坐标系变换到左手坐标系
            points.append(x)
    src.close()
    return points

@

注:POV-Ray 的坐标系是左手系。若待解析的三维点集数据位于右手系,那么在解析过程中需要对每个点的最后一个维度的坐标取相反数。

像这样的功能,用 C/C++ 之类的语言来写,会比较繁琐;用 POV-Ray 语言也能写得出来,依然会比较繁琐。

继续用 Python 语言将解析所得点集转化为 POV-Ray 模型,并以文件的形式保存:

@ points-to-pov.py # +
def output_points_model(points, model_name, dim = 3):
    model = open(model_name + '.inc', 'w')
    model.write('#declare ' + model_name + ' = union {\n') 
    for x in points:
        model.write('  sphere {<')
        for i in range(0, dim):
            if i < dim - 1:
                model.write('%f, ' % (x[i]))
            else:
                model.write('%f' % (x[i]))
        model.write('>, point_size_of_' + model_name + '}\n')
    model.write('} // ' + model_name + ' end\n') 
    model.close()

@

注:上述代码片段首部的 @ points-to-pov.py # ++ 符号表示在已存在的 points-to-pov.py 代码片段之后追加一段代码。

现在,只需将 points_parseroutput_points_model 组合起来便可将一份点集数据文件转化为 POV-Ray 点集模型文件。例如将 foo.asc 转化为 foo.inc 文件,并且二者位于同一目录:

points = points_parser('foo.asc')
output_points_model(points, 'foo')

若结合 Python 的命令行参数方式,便可写出一个可将任意一份三维点集数据转化为 POV-Ray 模型文件的小工具:

@ test.py # [Python]
# points-to-pov.py @

import os
import sys
if __name__=="__main__":
    path = sys.argv[1]
    (parent, file_name) = os.path.split(path)
    (model_name, ext_name) = os.path.splitext(file_name)
    points = points_parser(path)
    output_points_model(points, model_name)
@

注:上述代码片段中的 # points_model.py @ 表示将所有名为 points_model.py 的代码片段汇集于该语句出现之处。

为了照顾一下至此尚未看懂上述代码片段首部与尾部的那些 @ 代码片段 # [语言标记] + 或 ^+ 运算 之类符号的人,下面给出 test.py 的完整代码:

def points_parser(src_file_name, right_handed = True, dim = 3):
    points = []
    with open(src_file_name, 'r') as src:
        for line in src:
            coords = line.split()
            if len(coords) != dim:
                continue
            x = []
            for t in coords:
                x.append(float(t))
            if right_handed: 
                x[dim - 1] = -x[dim - 1] # 将 x 的坐标从右手坐标系变换到左手坐标系
            points.append(x)
    src.close()
    return points

def output_points_model(points, model_name, dim = 3):
    model = open(model_name + '.inc', 'w')
    model.write('#declare ' + model_name + ' = union {\n') 
    for x in points:
        model.write('  sphere {<')
        for i in range(0, dim):
            if i < dim - 1:
                model.write('%f, ' % (x[i]))
            else:
                model.write('%f' % (x[i]))
        model.write('>, point_size_of_' + model_name + '}\n')
    model.write('} // ' + model_name + ' end\n') 
    model.close()

import sys
if __name__=="__main__":
    path = sys.argv[1]
    (parent, file_name) = os.path.split(path)
    (model_name, ext_name) = os.path.splitext(file_name)
    points = points_parser(path)
    output_points_model(points, model_name)

实际上在我这里,上述代码是通过我写的一个名字叫 orez 的工具直接从这份文档中提取得到:

$ orez -t -e test.py python-meeting-povray.md

其中,python-meeting-povray.md 便是这份文档的名字。

试着让 Python 解释器执行

$ python3 test.py foo.asc

结果会在 src.asc 所在的目录中产生 foo.inc 文件,其内容类似:

#declare foo = union {
  sphere { <3.554705, 199.173300, 8.394049>, point_size_of_foo}
  sphere { <3.667395, 198.429900, 10.576820>, point_size_of_foo}
  ... ... ...
} // foo end

其中,foo_size 是小球的半径值,但是现在它只是一个尚未定义的变量,它的值需要在在视图中进行确定。

在视图看来,模型是什么?

前面说过,模型-视图-控制器这个范式,各个部分是分工合作的关系,而不是只分工不合作的关系。在上述的点集模型构造过程中,用于表示点的小球的半径是一个未定义的变量,它需要在视图中进行定义。因此,对于点集的绘制这一任务而言,视图与模型之间最基本的合作是视图需要为点集模型确定小球的半径。

下面是针对点集的视图与模型之间一种非常简单又粗暴的合作方式:

#declare foo_size = 0.1;
#include "foo.inc"
object {
  foo
  pigment {
    color rgb <0.5, 0.5, 0.5>
  }
}

这样,在视图文件中载入 foo.inc 时,foo_size 的值就是 0.1

这种简单粗暴的合作方式带来的问题是,foo_size 的取值有时会不合适,太小了,会导致点集不可见,太大了,看到的又往往是一堆相交的球体,以致看不清点集的形貌。

现在,姑且容忍这种简单又粗暴的方式,继续考虑为点集模型与视图之间是否还存在其他方面的联系,这需要从 POV-Ray 视图的基本结构开始考虑。

在 POV-Ray 视图结构中,首先要考虑相机的摆放,例如:

camera {
  location <x, y, z>
  look_at <x, y, z>
}

location 参数定义了相机的位置,look_at 参数定义的是相机待拍摄的三维场景的中心,即相机镜头光心要对准的位置。

对于拍摄点集模型而言,通常会希望点集能够完整且最大化的出现在所拍摄照片中,因此相机参数的设定依赖点集模型的位置与尺寸。

除了相机之外,POV-Ray 视图还需要光源。没有光,就没有图像。在 POV-Ray 视图里像上帝那样创造一个太阳,并不困难:

light_source {
  <x, y, z>
  color White
}

<x, y, z> 表示光源的位置。color White 表示光源的颜色是白光。光源的位置也依赖于点集模型的位置与尺寸,只是不像相机那样敏感。通常情况下,只要光源的位置足够高远,它总是能够照到待渲染的模型上的,例如:

light_source {
  <0, 5000, -5000>
  color White
}

这样的光源就类似于在一个位于 (0, 0, 0) 的物体的正前上方高挂着一个太阳。只要没有物体比这样的光源更高远,就无需考虑物体的位置与尺寸。

现在,待绘制的点集模型、相机与光源均已出现,它们构成了一幅完整的 POV-Ray 视图:

camera {
  location <x, y, z>
  look_at <x, y, z>
}

light_source {
  <x, y, z>
  color White
}

#declare foo_size = 0.1;
#include "foo.inc"
object {
  foo
  pigment {
    color rgb <0.5, 0.5, 0.5>
  }
}

相机、光源以及点的尺寸等参数的确定皆与绘制的点集模型的位置与尺寸相关。那么,点集模型的位置与尺寸该如何给出?一个简单又有效的方法是计算点集模型的轴向最小包围盒,以包围盒的中心作为点集的中心。相机与光源若都位于包围盒的外接球空间之外,并且相机的光心对准包围盒的中心,那么就可以保证点集模型可见并且总是位于相机的拍摄范围之内。至于点的尺寸,可将其视为包围盒外接球空间的最小长度单位,并使之与包围盒外接球半径成固定比例。

点集的包围球

下面的代码可计算基于点集的轴向最小包围盒的外接球的中心与半径:

@ points-to-pov.py # +
import math
def space_of_points(points, dim = 3):
    llc = []
    urc = []
    for i in range(0, dim):
        llc.append(sys.float_info.max)
        urc.append(-sys.float_info.max)
    for x in points:
        for i in range(0, dim):
            if llc[i] > x[i]:
                llc[i] = x[i]
            if urc[i] < x[i]:
                urc[i] = x[i]
    center = []
    squared_d = 0.0
    for i in range(0, dim):
        center.append(0.5 *  (urc[i] + llc[i]))
        t = urc[i] - llc[i]
        squared_d += t * t
    r = 0.5 * math.sqrt(squared_d)
    return (center, r)

@

生成 POV-Ray 视图文件

如上文所述,一旦获得了点集的包围球的中心与半径,便可进行相机、光源以及点的尺寸等参数的设定,从而生成 POV-Ray 视图文件。有了视图文件,便可由 POV-Ray 解析器生成渲染结果。不过,事情没那么简单。当然也没那么复杂。POV-Ray 解析器(至少 3.7 版本)对一些 POV-Ray 代码有一些硬性要求,即一些代码必须提供,否则就会给出警告信息。这部分代码与绘制点集模型基本上没有太大关系,因此下面将其隔离对待。此外,为了能让基本的渲染功能正常进行,也需要载入 POV-Ray 的一些预定义文件,例如颜色的预定义文件。可将这些代码看作是视图文件的「导言」:

@ points-to-pov.py # +
def view_prelude(view):
    prelude = [
        '#version 3.7;\n',
        '#include "colors.inc"\n',
        'background {color White}\n',
        'global_settings {assumed_gamma 1.0}\n\n'
    ]
    view.writelines(prelude)

@

下面开始考虑如何构造视图的基本要素。

首先给出点集包围球的中心与半径,并将包围球的中心作为视图的初始中心:

@ points-to-pov.py # +
def space_of_scene(view, x, r):
    view.write('#declare model_center = <%f, %f, %f>;\n' % ((x[0], x[1], x[2])))
    view.write('#declare model_radius = %f;\n' % (r))
    view.write('#declare view_center = model_center;\n\n')

@

然后摆放相机:

@ points-to-pov.py # +
def place_camera(view):
    view.write('camera {\n')
    view.write('  location <0, 0, -model_radius> + model_center * z\n')
    view.write('  look_at <0, 0, 0>\n')
    view.write('  translate view_center\n')
    view.write('}\n\n')

@

上述代码可将相机摆放点集模型的正前方,光心对准点集包围球的中心,并且相机的光心到点集包围球中心的距离与包围球半径相同。

注:POV-Ray 的坐标系是左手系,z 轴的正方向指向屏幕内部。因此,相机相对于点集的位移为负值,意味着沿 z 轴负方向移动。

再来看光源的设定:

@ points-to-pov.py # +
def place_light_source(view, color = [1.0, 1.0, 1.0]):
    view.write('light_source {\n')
    view.write('  model_center + <0, 0, -10 * model_radius>\n')
    view.write('  color rgb <%f, %f, %f>\n'  % (color[0], color[1], color[2]))
    view.write('    shadowless // 无影光源\n')
    view.write('}\n\n')

@

光源的位置被设定在相机的正上方,与相机的距离为 10 * model_radius

最后将点集模型放到三维场景中:

@ points-to-pov.py # +
def place_model(view, model_name, s, color = [0.5, 0.5, 0.5]):
    view.write('#declare point_size_of_' + model_name + ' = %f;\n' % (r * s))
    view.write('#include "' + model_name + '.inc"\n')
    view.write('object {\n')
    view.write('  ' + model_name + '\n')
    view.write('  pigment {\n')
    view.write('    color rgb <%f, %f, %f>\n'  % (color[0], color[1], color[2]))
    view.write('  }\n')
    view.write('}\n')

@

将上述函数组合起来,便可得到视图文件生成器:

@ test-2.py # [Python]
# points-to-pov.py @

import os
import sys
if __name__=="__main__":
    point_size = float(sys.argv[1])
    path = sys.argv[2]
    (parent, file_name) = os.path.split(path)
    (model_name, ext_name) = os.path.splitext(file_name)
    
    # 生成模型文件 
    points = points_parser(path)
    output_points_model(points, model_name)
    
    # 生成视图文件
    (center, r) = space_of_points(points)
    with open(model_name + '.pov', 'w') as view:
        view_prelude(view)
        space_of_scene(view, center, r)
        place_camera(view)
        place_light_source(view)
        place_model(view, model_name, point_size)
    view.close()
@

测试:

$ python3 test-2.py 0.003 foo.asc
$ povray +P foo.pov

0.003 是点的尺寸系数,它与点集的包围半径的积便是点的实际尺寸。

折腾到这里,终于能看到一张图了。上述命令最终得到的渲染结果为 foo.png:

当 Python 邂逅 POV-Ray

产生上述渲染结果的视图文件如下:

#version 3.7;
#include "colors.inc"
background {color White}
global_settings {assumed_gamma 1.0}

#declare model_center = <2.413898, 15.227750, 1.339995>;
#declare model_radius = 3.807916;
#declare view_center = model_center;

camera {
  location <0, 0, -model_radius> + model_center * z
  look_at <0, 0, 0>
  translate view_center
}

light_source {
  model_center + <0, 0, -10 * model_radius>
  color rgb <1.000000, 1.000000, 1.000000>
  shadowless
}

#declare point_size_of_points = 0.011424;
#include "points.inc"
object {
  points
  pigment {
    color rgb <0.500000, 0.500000, 0.500000>
  }
}

控制器

一番辛苦,看到的只是一幅简单的点集图像,的确很丟 POV-Ray 的脸,然而这就是所谓的「白描」。若想得到美仑美奂的渲染结果,不仅需要对 POV-Ray 足够熟悉,也需要具备一定的美术功底。不过,所有的修饰都集中在视图部分。模型是不变的。事实上能得到这种白描的结果,已经是迈出了一大步。

现在来考虑控制器的构建。与模型、视图的代码生成器相比,POV-Ray 的控制器更简单一些,因为根本不需要为它编写代码生成器。就像要得到精美的渲染结果只需要修改视图部分,控制器也是如此,一切只需要动手去修改视图文件。值得一提的是,POV-Ray 提供了制作动画的功能。利用这一功能,可以让上面的白描渲染结果动起来。老话说,一动不如一静,然而现代人看书的人少啊,看片的人多。

我要让上面所绘制的模型向左偏移 15 度角,然后再向右偏移 15 度角,即让它摇晃一个角度,轻轻摇晃,一点一点摇晃。要实现这一想法,只需将上述的视图文件 foo.pov 的 object 部分作以下修改:

#declare joggle = 30;
object {
  foo
  translate -model_center
  rotate #if (clock < 0.51) clock #else (1 - clock) #end * joggle * y
  translate model_center
  pigment {
    color rgb <0.300000, 0.300000, 0.300000>
  }
}

上述代码只是对点集模型增加了平移与旋转变换:(1) 平移点集模型,使其中心与坐标系原点重合;(2) 将点集模型向左缓慢偏移 15 度角,再向右缓慢偏移 15 度角;(3) 将点集的中心恢复到原来的位置。

然后在 foo.pov 的同一目录增加 foo.ini 文件,内容如下:

Input_File_Name = foo.pov
Initial_Frame = 1
Final_Frame = 30

接下来,将 POV-Ray 解析器作用于 foo.ini:

$ povray foo.ini

上述命令需要一些时间,待其运行结束后,会产生 30 幅图片,名称为 foo01.png, foo02.png, ... foo30.png。使用 imagemagick 工具箱的 convert 命令可将这组图片合成为 GIF 动图 foo.gif:

$ convert -delay 10 -loop 0 foo*.png foo.gif

结果如下图所示:

当 Python 邂逅 POV-Ray

结语

虽然本文档仅介绍了点集模型的绘制,但是对于更复杂的图形绘制而言, 0 和 1 已经有了,剩下的事情是 0 和 1 的组合。

附录

  1. POV-Ray 3.7 指南
  2. 更丰富的 POV-Ray 指南
  3. Dive Into Python 3