上次我们生成了城市布局,接下来是正式生成建筑啦 ☆*: .。. o(≧▽≦)o .。.:*☆

在开始之前,如果你已经做好了城市的布局,一定不要边做边在布局上批量放建筑,据测试,每个建筑只放一楼的面,需要约48GB运行内存。

预处理输入信息

输入是一个单片元,边界样条,另外附加两个,附加空白区域和附加阻碍区域。

首先先依据边界样条的BBox将所有输入对齐到原点上。

获得建筑区域

程序切割的建筑区域建筑未必要占满,因为有些地方太窄,生成过去会很奇怪。

所以我们先对输入边界样条进行三步操作:

  • PolyExpand2D缩小,这样太窄的地方就会缩没

  • PolyExpand2D扩大,扩大到原本的大小

  • 布尔,结果与原始样条交集,因为两步操作后有可能少了某些边导致扩大后比以前更大了。

这样就得到了建筑区域。

(不要忘记计算原始边界样条与建筑区域的差集哦)

获得阻碍区域

我们在生成布局时,给沿马路的边进行了标记。

所以未被标记的边会相邻其他建筑,这些地方视为有遮挡。

将未标记的边在地平面上先挤出足够长,得到一些平面,再向上挤出足够高,得到一些多面体。

多面体布尔上附加阻碍区域,布尔减去附加空白区域就得到了最终阻碍区域。

生成楼房

先生成最简单的——楼房

一个Subnet,输入两个参数:单片元,边界样条,表示生成范围;非空白遮罩,闭合的多面体,用于确定对应方向是否可以生成凸出的东西。

生成放置点位

我们的墙体需要生成在输入的片元的边上,先对输入片元做一次重采样(Resample),此处我的所有模型模块宽度都是3的倍数,所以重采样采3。

为了让生成的墙尽可能贴着边界样条,需要勾选Resample by Polygon Edge

然后每面墙应该是要放在边的正中心上,所以需要计算边缘中心,此处需要写Vex(这个Vex是逐片元的):

int pt_count = len(primpoints(0, @primnum));

for (int i = 0; i < pt_count; i++) {
    int pt1 = primpoint(0, @primnum, i);
    int pt2 = primpoint(0, @primnum, (i + 1) % pt_count);  

    vector pos1 = point(0, "P", pt1);
    vector pos2 = point(0, "P", pt2);
    float dis = length(pos1 - pos2);

    vector mid_point = (pos1 + pos2) / 2;

    // 创建新点(中点位置)
    int new_pt = addpoint(0, mid_point);
    
    setpointgroup(0,"summonPoint",new_pt,1);
    setpointattrib(0,"scale",new_pt,set(dis / 3,1,1));
    // 计算边的方向
    vector edge_dir = normalize(pos1 - pos2);
    vector ref = {1,0,0};
    vector axis = cross(ref,edge_dir);
    float axisLen = length(axis);
    if(axisLen < 0.0001){
        axis = {0,1,0};
    }else{
        axis = axis / axisLen;
    }
    float angle = acos(dot(ref,edge_dir));
    vector4 rot = quaternion(angle,axis);  
    setpointattrib(0,"rot",new_pt,rot);
    setpointattrib(0,"dir",new_pt,edge_dir);
    
    setpointattrib(0,"need",new_pt,"");

    // 计算遮挡
    vector testPoint = mid_point + cross(edge_dir,set(0,1,0)) * 1.5 + set(0,1.5,0);
    int barrier = volumesample(1,0,testPoint) < 0.1 ? 0 : 1;
    setpointattrib(0,"barrier",new_pt,barrier);
}

这段脚本的第二个引脚上需要连接一个VDB,用于确定某一个面前面是否有遮挡,如图:

这段Vex做了这些事情:

  • 计算每个边的中点,创建

  • 计算每个中点的X缩放,因为采样长度大概率不是3,需要缩放模型,写进scale

  • 计算每个边的方向,用于后续脚本处理方便,写进中点dir

  • 给每个点加了空串need,之后用于指定某块必须生成某模块。

  • 计算每个方向的旋转,用于旋转模型,墙壁模型需要旋转才能贴合样条,写进rot

  • 计算每个中点前面是否有遮挡,写进barrier

  • 给生成中点的点打进summonPoint

使用图表生成

我测试了使用图表逻辑去确定每一块是什么,我在生成块时稍微写了一点点逻辑,心智开销就很大了,感觉图表并不适合做这件事情(⊙﹏⊙)

用Python生成

越来越离不开Python了呢

Python负责给之前生成的放置点位设置属性,属性包含了点位要放置什么模块。

写成数据驱动最舒服了。

在传给Python之前先确定好有多少层。

三维的东西处理起来是麻烦,但楼房从一个垂直边把表面裁开,这不就是一个 样条采样点数 * 楼高 的矩阵嘛。

乱七八糟的逻辑无非就是这几种:

  • 一种模块旁边(上下左右)必须是另一种模块

  • 模块位置必须有空间

  • 模块必须在最下面或最上面

同时,用Python做还允许我们添加权重。

于是,我制定这个的数据表格规则:

file

keywords

weigh

req_left

req_up

req_down

req_empty

模型文件名

关键词

权重

相邻关键词

相邻关键词

相邻关键词

需要有空间

每个条目可以有多个关键词,用"|"分隔,比如,”上方完整的“、”有竖直装饰的“、”突出的“,req_XXX表示这一模块的某一方向的其他模块需要什么关键词。

有几个特殊关键字:

  • top、bottom:用在req_XXX里,超出矩阵垂直索引时,索引以外,上面是top,下面是bottom,req_up=top的模块永远会被放在最顶层,req_down=bottom永远会被放在最底层(比如门)。

  • conn、noconn:用在req_XXX里,仅限左右方向,表示左右方向相邻的块是否在同一平面上,如果在同一平面上,有conn,否则有noconn

  • default:没用,简化程序的,每个条目会自动默认加上这个keyword。

考虑到可能有块需要水平翻转,因此req_XXX允许在每个关键字前面加上 正负号,表示是否反转过,不加就是都可以。

规则比较简单,波函数坍缩算法优先队列进来出去的效率低,不如直接用回溯算法,模块关系不应该太复杂,复杂的关系导致算法回溯会大大降低效率。

算法假定传入的点数是层数的倍数、点按照先顺时针,再从下到上的顺序排列。

同一层上相邻的两个点,只有在方向的夹角足够小时,才会算作相邻。

壮观的Python脚本(建议参考https://srcblog.ffeng123.win:23443/archives/284179f7-25c4-4fe9-894f-b575758dfdbd把这个文件放到内嵌Python的库目录里):

import csv
import random
import json


class Matrix:
    def __init__(self, size: tuple[int, int]):
        self.size = size
        self.data = [None for _ in range(size[0] * size[1])]

    def __getitem__(self, index: tuple[int, int]):
        return self.data[(index[0] % self.size[0]) + self.size[0] * (index[1] % self.size[1])]

    def __setitem__(self, index: tuple[int, int], value):
        self.data[(index[0] % self.size[0]) + self.size[0]
                  * (index[1] % self.size[1])] = value

    def save(self, file: str):
        with open(file, 'w') as f:
            json.dump({'size': self.size, 'data': self.data}, f)

    @staticmethod
    def load(file: str):
        with open(file, 'r') as f:
            obj = json.load(f)
            matrix = Matrix(tuple(obj['size']))
            matrix.data = obj['data']
            return matrix


class Builder:
    def __init__(self, csv_file):
        self.blocks = []
        self.keymap = {}

        self.default_mask = 1 << self._query_keyword("default")
        self.top_mask = 1 << self._query_keyword("top")
        self.bottom_mask = 1 << self._query_keyword("bottom")
        self.noconn_mask = 1 << self._query_keyword("noconn")
        self.conn_mask = 1 << self._query_keyword("conn")
        self.no_sign_keyword = {
            "default": True,
            "top": True,
            "bottom": True,
            "noconn": True,
            "conn": True,
        }

        self.csv_file = csv_file
        with open(self.csv_file, encoding='utf_8_sig') as f:
            reader = csv.reader(f)
            header_map = {header: index for index,
                          header in enumerate(next(reader))}
            for row in reader:
                keywords = self._split_keywords(row[header_map["keywords"]])
                req_left = self._split_keywords(row[header_map["req_left"]])
                req_right = self._split_keywords(row[header_map["req_right"]])
                req_up = self._split_keywords(row[header_map["req_up"]])
                req_down = self._split_keywords(row[header_map["req_down"]])
                bt = {
                    "req_up": self._query_target_keywords_mask(self._convert_target_keyword(req_up))if len(req_up) != 0 else ~0,
                    "req_down": self._query_target_keywords_mask(self._convert_target_keyword(req_down))if len(req_down) != 0 else ~0,
                    "req_empty": bool(row[header_map["req_empty"]]),
                    "weight": float(row[header_map["weight"]]),
                    "file": row[header_map["file"]],
                    "keywordsrc": keywords,
                }
                if (bt["weight"] == 0):
                    continue
                self.blocks.append({
                    "keyword": self._query_target_keywords_mask(self._convert_target_keyword(keywords, True)) | self.default_mask,
                    "req_left": self._query_target_keywords_mask(self._convert_target_keyword(req_left))if len(req_left) != 0 else ~0,
                    "req_right": self._query_target_keywords_mask(self._convert_target_keyword(req_right))if len(req_right) != 0 else ~0,
                    "turn": False,
                    **bt,
                })
                self.blocks.append({
                    "keyword": self._query_target_keywords_mask(self._convert_target_keyword(keywords, False)) | self.default_mask,
                    "req_left": self._query_target_keywords_mask(self._convert_turn_keyword(self._convert_target_keyword(req_right)))if len(req_right) != 0 else ~0,
                    "req_right": self._query_target_keywords_mask(self._convert_turn_keyword(self._convert_target_keyword(req_left)))if len(req_left) != 0 else ~0,
                    "turn": True,
                    **bt,
                })

    def _split_keywords(self, keywords: str) -> list[str]:
        ls = keywords.split("|")
        return [k for k in ls if k]

    def resolving(self, meta_matrix: Matrix):
        size = meta_matrix.size
        out = Matrix(size)

        def get_mask(x, y):
            if y < 0:
                return self.top_mask
            if y >= size[1]:
                return self.bottom_mask
            hasv = out[x, y]
            if not hasv is None:
                return hasv["keyword"]
            # 尚未坍缩
            return ~(self.bottom_mask | self.conn_mask | self.noconn_mask | self.top_mask)
        stack = [(0, 0, 0)]
        count = 0
        while len(stack) > 0:
            count += 1
            if count > 1000000:
                raise ValueError("生成失败: 尝试次数过多")
            x, y, retry = stack.pop()
            if retry > 1:
                continue
            # mask
            reqs = []
            if y > 0:
                reqs.append(out[x, y-1]["req_down"])
            if x > 0 and meta_matrix[x, y]["lconn"]:
                reqs.append(out[x-1, y]["req_right"])
            need = meta_matrix[x, y]["need"]
            if need:
                reqs.append(self._query_target_keywords_mask(
                    self._convert_target_keyword(need)))
            if x == size[0] - 1 and meta_matrix[x, y]["rconn"]:  # 水平循环
                reqs.append(out[x+1, y]["req_left"])
            lmask = (get_mask(
                x-1, y) | self.conn_mask) if meta_matrix[x, y]["lconn"] else self.noconn_mask
            tmask = get_mask(x, y-1)
            rmask = (get_mask(
                x+1, y) | self.conn_mask) if meta_matrix[x, y]["rconn"] else self.noconn_mask
            bmask = get_mask(x, y+1)
            sels = []
            wsum = 0
            for block in self.blocks:
                if block["req_empty"] and (not meta_matrix[x, y]["empty"]):
                    continue
                # 周围的块是被需要的
                if block["req_left"] & lmask == 0:
                    continue
                if block["req_up"] & tmask == 0:
                    continue
                if block["req_right"] & rmask == 0:
                    continue
                if block["req_down"] & bmask == 0:
                    continue
                # 这个块是被需要的
                failed = False
                for req in reqs:
                    if (req & block["keyword"] == 0):
                        failed = True
                        break
                if failed:
                    continue

                sels.append(block)
                wsum += block["weight"]
            if len(sels) == 0:
                continue
            stack.append((x, y, retry+1))
            # 选择
            w = random.random() * wsum
            for block in sels:
                w -= block["weight"]
                if w <= 0:
                    out[x, y] = block
                    break
            if out[x, y] is None:
                out[x, y] = sels[-1]
            # 成功
            if (x == size[0] - 1 and y == size[1] - 1):
                return out
            # 下一个
            x += 1
            y += x // size[0]
            x %= size[0]
            stack.append((x, y, 0))
        raise ValueError("生成失败")

    def _query_keyword(self, keyword) -> int:
        v = self.keymap.get(keyword)
        if v is None:
            self.keymap[keyword] = len(self.keymap)
            return self.keymap[keyword]
        return v

    def _query_target_keywords_mask(self, keywords: list) -> int:
        mask = 0
        for keyword in keywords:
            mask |= 1 << self._query_keyword(keyword)
        return mask

    def _convert_target_keyword(self, keywords: list, needType: None | bool = None) -> list:
        kwds = []
        for k in keywords:
            if k[0] == "+" or k[0] == "-" or self.no_sign_keyword.get(k) is True:
                kwds.append(k)
            else:
                if (needType != True):
                    kwds.append("-" + k)
                if (needType != False):
                    kwds.append("+" + k)
        return kwds

    def _convert_turn_keyword(self, keywords: list) -> list:
        kwds = []
        for k in keywords:
            if k[0] == "+" or k[0] == "-":
                if k[0] == "+":
                    kwds.append("-" + k[1:])
                else:
                    kwds.append("+" + k[1:])
            else:
                kwds.append(k)
        return kwds


def _point_idx_to_xy(i: int, size: tuple[int, int]):
    return (size[0] - i % size[0] - 1, size[1] - i // size[0] - 1)


def _xy_to_point_idx(xy: tuple[int, int], size: tuple[int, int]):
    return size[0] * (size[1] - xy[1] - 1) + size[0] - xy[0] - 1


def _is_dir_same(dir1: tuple[float, float, float], dir2: tuple[float, float, float]):
    dot = dir1[0] * dir2[0] + dir1[1] * dir2[1] + dir1[2] * dir2[2]
    return dot > 0.9


def get_input_from_hou() -> Matrix:
    import hou
    geo = hou.pwd().geometry()

    def get_int(name):
        node = hou.pwd()
        parm = node.parm(name)
        if parm is None:
            parm_group = node.parmTemplateGroup()
            parmi = hou.IntParmTemplate(name, name, 1)
            parm_group.append(parmi)
            node.setParmTemplateGroup(parm_group)
            return 0
        else:
            return parm.eval()
    h = get_int("height")
    if (h == 0):
        h = 1
    points = geo.points()
    size = (len(points) // h, h)
    meta = Matrix(size)
    for i, point in enumerate(points):
        x, y = _point_idx_to_xy(i, size)

        ileft = _xy_to_point_idx(((x + size[0] - 1) % size[0], y), size)
        iright = _xy_to_point_idx(((x + 1) % size[0], y), size)
        dir = point.floatListAttribValue("dir")
        ldir = points[ileft].floatListAttribValue("dir")
        rdir = points[iright].floatListAttribValue("dir")

        need = point.stringAttribValue("need")
        meta[x, y] = {
            "need": need.split("|") if need else None,
            "empty": point.intAttribValue("barrier") == 0,
            "point": point,
            "lconn": _is_dir_same(dir, ldir),
            "rconn": _is_dir_same(dir, rdir),
        }
    return meta


def set_output_to_hou(meta: Matrix, out: Matrix):
    import hou
    geo = hou.pwd().geometry()
    groups = {}

    def get_group(name):
        gp = groups.get(name)
        if gp is None:
            gp = geo.findPointGroup(name)
            if gp is None:
                gp = geo.createPointGroup(name)
            groups[name] = gp
        return gp

    geo.addAttrib(hou.attribType.Point, "block", "")
    geo.addAttrib(hou.attribType.Point, "turn", 0)
    for i in range(len(meta.data)):
        x, y = _point_idx_to_xy(i, meta.size)
        point = meta[x, y]["point"]
        block = out[x, y]
        # 打组
        for kwd in block["keywordsrc"]:
            get_group(kwd).add(point)
        # 目标
        point.setAttribValue("block", block["file"])
        point.setAttribValue("turn", 1 if block["turn"] else 0)

这样去调用:

import hou
from ffutils.generator import (
    Builder,
    get_input_from_hou,
    set_output_to_hou,
)

if hou.pwd().geometry().point(0) != None:
    builder = Builder(hou.pwd().parm("data").eval())
    meta = get_input_from_hou()
    out = builder.resolving(meta)
    set_output_to_hou(meta,out)

(里面的if判断是有必要的,保护一下里面脆弱的代码,万一传个空的几何体里面可是会有除零错误的哦)

于是就得到了这样子的效果:

(小贴士:这段脆弱的Python代码没什么鲁棒性)

封顶

有屋顶的模块,但是方方正正的并不适合我们用在角度不确定的房子上,所以,给上面的边挤出,做房顶。

出于方便考虑,给封顶的最顶上的面打个组再输出。

生成楼房上的楼房

这样一个上边和下边一样粗细的建筑似乎有点太单调了。

以刚刚生成的楼房的顶面再生成一个楼房如何?

切割底面

新楼房底肯定不能和原本的底一样大,不然不就成一个了嘛

这里就比较随意啦,我的做法是,比较靠中间的地方随机选择一个点,随机往一个方向Clip。

于是就得到了:

(小贴士:为了避免第二层被切的太小,奇怪,最好将本文开头的预处理再给第二层做一遍)

围墙

我们要在第二层生成时被切掉的部分上生成围墙。

拿一层的房顶布尔掉二楼的底面就是要围围墙的区域。

同时,布尔的组参数A-B Seams 可以标记出相接的边,不生成围墙。

这里的围墙我并没有合适的模块,就简单地拿程序来画啦。

最后

考虑到还要往建筑上加东西,建筑的节点最好有两个输出,除了输出建好的网格,还把生成时用到的点也输出出去。

建筑的网格是模块拼的,受浮点精度损失影响,并不是很严丝合缝,(而且为了性能考虑),跟一个Fuse节点合并一下重叠的点。

然后重新计算法线(这是必要的),输出。


于是——我们得到了一个基础的程序化建筑。

但不要着急把它放到城市布局里面去,除非你有那种存储比内存都大的服务器(❁´◡`❁)

我能想到的,最大的成功就是无愧于自己的心。