上次我们生成了城市布局,接下来是正式生成建筑啦 ☆*: .。. 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做还允许我们添加权重。
于是,我制定这个的数据表格规则:
每个条目可以有多个关键词,用"|"分隔,比如,”上方完整的“、”有竖直装饰的“、”突出的“,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脚本(建议参考
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节点合并一下重叠的点。
然后重新计算法线(这是必要的),输出。
于是——我们得到了一个基础的程序化建筑。
但不要着急把它放到城市布局里面去,除非你有那种存储比内存都大的服务器(❁´◡`❁)