添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
99行代码能干啥?造个体素小世界!

99行代码能干啥?造个体素小世界!

1 年前 · 来自专栏 图形之道

一起用 91 行 Taichi 代码生成秋日落叶小场景!

(文章发布后我们收到 112 份作品!社区同学令人叹为观止的作品请见文末 :-)

缘起

两三个月前,我和匡冶去上海出差,周日下午坐高铁回北京。忙活了一周,我们俩都精疲力竭地坐在高铁上。还有3个小时才到北京,不如写点代码消磨时间,也算放松一下身心。正好聊到 Minecraft (“别人的世界”) 和 MagicaVoxel,于是我们决定来个 Hackathon,用 Taichi 整个 GPU 光线追踪体素渲染器。在快到北京南站之前,我们有了第一版雏形:

高铁上的第一版渲染器原型 https://www.zhihu.com/video/1503434212808486913

Taichi 是嵌入在 Python 中的并行编程语言,这使得我们的渲染器可以几乎在任何操作系统上运行,并且和 Python 很容易地交互。据我们所知,目前 Python 生态系统里面还没有工具能够实现跨平台的 GPU 光线追踪体素渲染器。Taichi 只需要大约 300 行代码就可以实现: github.com/taichi-dev/v

写完代码,我们才发现一个更严峻的问题:对我们老图形程序员来说,造个光线追踪渲染器容易,渲染出好看的图反而更难。加上写 UI 是个工作量很大的事情,我们只支持一个体素一个体素地编辑,这得猴年马月才能做出我们想要的结果...

好在 “批量操作体素” 这个事情本身也是可以写代码完成的。Minecraft 大神们可以用各种搭建技法创建自己的体素世界,咱没那个技法,要不直接写代码生成吧!开动!

写代码生成一个底座,然后点鼠标加入一些蓝色的体素。手残党表示:你还是让我全部用代码生成吧...

“愿景”

做任何事情之前先要有个美好的愿景,才能确保自己在正确的方向上。在网上找了一些 Voxel 大佬的艺术作品,发现 森林 是一个常见题材。于是我找了找森林的照片,找到一张秋天的,意境很不错:

于是我决定照着这张照片和一些体素艺术家的作品,还原一个秋天的场景,再用光线追踪渲染出来,应该会挺有意思~

以下的内容只需要一些基础的 Python 知识就可以阅读。Taichi 是一个嵌入在 Python 中的小语言,能够让你的程序被 GPU 加速。如果对 Taichi 还不是很了解,可以参考下面这个链接:

运行代码,3D 漫游!

Follow 完这个教程,你会得到一个 3D 场景,可以在里面漫游。代码是跨平台的,我的 Macbook 笔记本上也可以运行(20 FPS)。如果你有 RTX 3090 之类的核武器那运行得会更流畅。我的笔记本上移动相机的时候会稍有噪点,停下来很快就收敛了:

(小笔记本上录制,两倍速播放) https://www.zhihu.com/video/1503689003711324160

如果你想体验这个场景,可以 clone github.com/taichi-dev/v 。如果你想创建自己的场景,可以从我们的模板仓库 GitHub - taichi-dev/voxel-challenge 开始:

点击 Use this template 即可创建你自己的仓库,改 main.py 进行创作

打个广告,我们正在办一个 Taichi 体素创意大赛,更多规则请见 GtiHub,欢迎有兴趣的同学参加,一起切磋代码技能!

另外有同学反馈表示 1.0.1 在某些环境上有些 Vulkan 兼容性的问题,使用 1.0.0 就可以解决: pip install taichi==1.0.0 相关 issue: github.com/taichi-dev/t 社区在修了... 如果没遇到这个问题可以无视这一段就好。

用体素搭建秋日小树林

先分析一下“愿景图”,里面有几个关键元素:枫(?)树,铺满落叶的地面,和体积雾。我们的渲染器并不支持体积雾,好在我们可以用一个45°的斜阳(directional_light)加上偏黄的色温来模拟这个场景。

from scene import Scene
import taichi as ti
from taichi.math import *
scene = Scene(voxel_edges=0, exposure=2) # 创建场景,指定体素描边宽度和曝光值
scene.set_floor(0, (1.0, 1.0, 1.0)) # 地面高度
scene.set_background_color((0.5, 0.5, 0.4)) # 天空颜色
scene.set_directional_light((1, 1, -1), 0.2, (1, 0.8, 0.6)) # 光线方向和颜色
@ti.kernel
def initialize_voxels():
    scene.set_voxel(vec3(0), 1, vec3(1)) # 在 (0, 0, 0) 加入一个白色 (1, 1, 1) 的体素!
initialize_voxels()
scene.finish()

你就能得到如下场景:


先来一个小方块,调下光的方向和色温,来点秋天的感觉。(不要问我为啥 voxel_edge=0了还有描边,问就是浮点误差,离远点就看不见了...)


基座

看了几个大佬的体素作品,发现他们常用的一个技法是 “蛋糕切块”,也就是通过截面表现一些平时不容易看到的东西,比如说土地内部的结构,作为作品的基座:

蛋糕切块效果...

那我们依葫芦画瓢,做一个泥土的基座。

画个饼:读完这一节你就能做出我们的底座,看起来还有点泥土的味道...


其实也不是太难,我们一层一层来。如果只考虑相同颜色的一层,那其实就是一个立方体,带上一些随机性。

我们首先要实现一个函数,来生成一个从 (pos[0], pos[1], pos[2]) 开始,大小是 size[0] x size[1] x size[2] 的立方体。当然,我们可以指定颜色 color。为了增加真实感,我们不能让这个立方体看起来太完美,所以加上一些噪声 color_noise:

@ti.func
def create_block(pos, size, color, color_noise):
    for I in ti.grouped(
            ti.ndrange((pos[0], pos[0] + size[0]), 
                       (pos[1], pos[1] + size[1]),
                       (pos[2], pos[2] + size[2]))):
        scene.set_voxel(I, 1, color + color_noise * ti.random())

这里利用过了一个技巧: ti.ndrange ,它能够在一行中实现一个多层 for 循环(否则要写 3 层 for loop,比较麻烦)。配上 ti.grouped ,我们将 i, j, k 三个循环变量塞到一个向量 I = (i, j, k) 里面。(表达能力有限,这里我没讲清楚的话,请参考 小教程 ~)

这里有了这个函数,我们就可以绘制一个大大的方块:

@ti.kernel
def initialize_voxels():
    create_block(pos=ivec3(0, 0, 0),
                 size=ivec3(20, 40, 30),
                 color=vec3(0.3, 0.5, 0.3),
                 color_noise=vec3(0.1))

注意

从 (0, 0, 0) 开始长出来的一个 20x40x30 的方块

紧接着,我们只要用一个 4 层的 for 循环,把每一层绘制出来:

@ti.kernel
def initialize_voxels():
    for i in range(4):
        create_block(ivec3(-60, -(i + 1)**2 - 40, -60),
                     ivec3(120, 2 * i + 1, 120),
                     vec3(0.5 - i * 0.1) * vec3(1.0, 0.8, 0.6),
                     vec3(0.05 * (3 - i))) 
这样你就得到了一个淡黄的提拉米苏...

现在有个问题:地表颜色有点淡,我们来加一层地表

看起来更美味了...

好的,说回来,我们并不是在做蛋糕,但是这个蛋糕形状的底座作为整个作品的基座还是不错的。于是,我们的基座就这样完成了!


树和落叶

有了基座,我们来点树。


新的饼:读完这一节会知道这个树怎么搞出来 :-)

首先,因为我们有很多树,代码行数也有限,我们需要有个画树的函数:


@ti.func
def create_tree(pos, height, radius, color):
    ...    


四个参数:

  • pos: 树根的位置
  • height:树的高度
  • radius:树叶半径
  • color:树叶颜色


我们一步步来。先搞个树干。这个比较简单,reuse 我们之前的 create_block 就好:


@ti.func
def create_tree(pos, height, radius, color):
    create_block(pos, ivec3(3, height - radius * 0.5, 3), vec3(0.7), vec3(0.3))


只有光秃秃的树干好像还有些枯燥。我们来加点叶子。这里我们加一个函数。先假设一棵树的叶子分布的区域是一个圆柱,圆柱中的每一个 voxel 都有一定概率存在或者不存在。我们希望这个概率离圆柱重心越远,就越小。于是我们得到了下面这个函数:


@ti.func
def create_leaves(pos, radius, color):
    for I in ti.grouped(
            ti.ndrange((-radius, radius), (-radius, radius),
                       (-radius, +radius))):
        f = I / radius
        d = vec2(f[0], f[2]).norm() # 到圆柱中轴线的距离
        prob = max(0, 1 - d)**2
        if ti.random() < prob:
            scene.set_voxel(pos + I, 1, color + (ti.random() - 0.5) * 0.2)
这个树长得有点像冰棍...

好吧,看起来不是很自然... 通过一些脑补出来的数学函数和一些噪声,我们稍微优化一下:


@ti.func
def create_leaves(pos, radius, color):
    for I in ti.grouped(
            ti.ndrange((-radius, radius), (-radius, radius),
                       (-radius, +radius))):
        f = I / radius
        h = 0.5 - max(f[1], -0.5) * 0.5
        d = vec2(f[0], f[2]).norm()
        prob = max(0, 1 - d)**2 * h  # xz mask
        prob *= h  # y mask
        # noise
        prob += ti.sin(f[0] * 5 + pos[0]) * 0.02
        prob += ti.sin(f[1] * 9 + pos[1]) * 0.01
        prob += ti.sin(f[2] * 10 + pos[2]) * 0.03
        if prob < 0.1:
            prob = 0.0




    

        if ti.random() < prob:
            scene.set_voxel(pos + I, 1, color + (ti.random() - 0.5) * 0.2)


这个函数还有些复杂,但是整体的思路是对于树叶区域的每个体素,计算一个概率,概率和这个体素在空间中的位置有一些关系,还有一些噪声。加上其他细节,就大功告成啦!


加上一些细节,更像树了!(同事:你这明明是更像一个炸鸡腿..)


因为我们在做秋天,应该有一些落叶。其实也很简单,就是在树下的圆盘区域里面随机撒一些点即可:

    for i, j in ti.ndrange((-radius, radius), (-radius, radius)):
        prob = max((radius - vec2(i, j).norm()) / radius, 0)
        prob = prob * prob
        if ti.random() < prob * prob:
            scene.set_voxel(pos + ivec3(i, 1, j), 1,
                            color + ti.random() * vec3(0.1))


在底部圆盘里面加一些随机的体素,模拟落叶,显得我们并不是炸鸡腿...

我们只要多调用几次 create_tree,就有了一片小树林。注意优化一下每棵树的位置、高度、颜色:

    create_tree(ivec3(-20, -40, 25), 65, 35, vec3(1.0, 0.3, 0.15))
    create_tree(ivec3(45, -40, -45), 15, 10, vec3(0.8, 0.4, 0.1))
    create_tree(ivec3(20, -40, 0), 45, 25, vec3(1.0, 0.4, 0.1))
    create_tree(ivec3(30, -40, -20), 25, 15, vec3(1.0, 0.4, 0.1))
    create_tree(ivec3(30, -40, 30), 45, 25, vec3(1.0, 0.4, 0.1))
树的部分收工!

围栏

最后一步了,其实也比较简单,就是沿着一个方向加入一段长条,然后每隔一段距离加个小竖条就行:

ti.func
def make_fence(start, direction, length):
    color = vec3(0.5, 0.3, 0.2)
    create_block(start, direction * length + ivec3(3, 2, 3), color, vec3(0.1))
    fence_dist = 3
    for i in range(length // fence_dist + 1):
        create_block(start + direction * i * fence_dist + ivec3(1, -3, 1),
                     ivec3(1, 5, 1), color, vec3(0))


调用一次 make_fence...


直接乘四,大功告成

至此,我们完成了基座、树和落叶、围栏,收工!

选个好的角度,按 P 截图...
用 W/S/A/D/Q/E 开启摄影漫游模式,按 P 截图。好久不摸相机了,在体素世界里安慰一下自己作为摄影爱好者受伤的心灵吧 :-)


完整的91行代码

仓库地址: github.com/yuanming-hu/

from scene import Scene
import taichi as ti
from taichi.math import *
scene = Scene(voxel_edges=0, exposure=2)
scene.set_floor(-0.85, (1.0, 1.0, 1.0))
scene.set_background_color((0.5, 0.5




    
, 0.4))
scene.set_directional_light((1, 1, -1), 0.2, (1, 0.8, 0.6))
@ti.func
def create_block(pos, size, color, color_noise):
    for I in ti.grouped(
            ti.ndrange((pos[0], pos[0] + size[0]), (pos[1], pos[1] + size[1]),
                       (pos[2], pos[2] + size[2]))):
        scene.set_voxel(I, 1, color + color_noise * ti.random())
@ti.func
def create_leaves(pos, radius, color):
    for I in ti.grouped(
            ti.ndrange((-radius, radius), (-radius, radius),
                       (-radius, +radius))):
        f = I / radius
        h = 0.5 - max(f[1], -0.5) * 0.5
        d = vec2(f[0], f[2]).norm()
        prob = max(0, 1 - d)**2 * h  # xz mask
        prob *= h  # y mask
        # noise
        prob += ti.sin(f[0] * 5 + pos[0]) * 0.02
        prob += ti.sin(f[1] * 9 + pos[1]) * 0.01
        prob += ti.sin(f[2] * 10 + pos[2]) * 0.03
        if prob < 0.1:
            prob = 0.0
        if ti.random() < prob:
            scene.set_voxel(pos + I, 1, color + (ti.random() - 0.5) * 0.2)
@ti.func
def create_tree(pos, height, radius, color):
    create_block(pos, ivec3(3, height - radius * 0.5, 3), vec3(0.7), vec3(0.3))
    # Leaves
    create_leaves(pos + ivec3(0, height, 0), radius, color)
    # Ground
    for i, j in ti.ndrange((-radius, radius), (-radius, radius)):
        prob = max((radius - vec2(i, j).norm()) / radius, 0)
        prob = prob * prob
        if ti.random() < prob * prob:
            scene.set_voxel(pos + ivec3(i, 1, j), 1,
                            color + ti.random() * vec3(0.1))
@ti.func
def make_fence(start, direction, length):
    color = vec3(0.5, 0.3, 0.2)
    create_block(start, direction * length + ivec3(3, 2, 3), color, vec3(0.1))
    fence_dist = 3
    for i in range(length // fence_dist + 1):
        create_block(start + direction * i * fence_dist + ivec3(1, -3, 1),
                     ivec3(1, 5, 1), color, vec3(0))
@ti.kernel
def initialize_voxels():
    for i in range(4):
        create_block(ivec3(-60, -(i + 1)**2 - 40, -60),
                     ivec3(120, 2 * i + 1, 120),
                     vec3(0.5 - i * 0.1) * vec3(1.0, 0.8, 0.6),
                     vec3(0.05 * (3 - i)))
    create_block(ivec3(-60, -40, -60), ivec3(120, 1, 120), vec3(0.3, 0.2, 0.1),
                 vec3(0.01))
    create_tree(ivec3(-20, -40, 25), 65, 35, vec3(1.0, 0.3, 0.15))
    create_tree(ivec3(45, -40, -45), 15, 10, vec3(0.8, 0.4, 0.1))
    create_tree(ivec3(20, -40, 0), 45, 25, vec3(1.0, 0.4, 0.1))
    create_tree(ivec3(30, -40, -20), 25, 15, vec3(1.0, 0.4, 0.1))
    create_tree(ivec3(30, -40, 30), 45, 25, vec3(1.0, 0.4, 0.1))
    make_fence(ivec3(-58, -36, -58), ivec3(1, 0, 0), 115)
    make_fence(ivec3(-59, -36, 57), ivec3(1, 0, 0), 115)
    make_fence(ivec3(-59, -36, -58), ivec3(0, 0, 1), 115)