← 日志列表 / All journal entries

Houdini 中的约束驱动道路生成

Constraint-Driven Road Generation in Houdini

PCG村庄项目中的道路生成

Road Generation in the PCG Village Project

程序化道路最直观的做法,是先有中心线,再给宽度,最后用 Sweep 生成路面。

我在这个村镇项目里遇到的问题,不在 Sweep。

中心线可以成立,设好的道路参数却不一定能直接用。

道路有不同等级,也有不同宽度。路口的夹角有时很小。部分路段很短。它们没有足够空间容纳原来的尺寸。道路进入起伏地形后,横坡也会影响路宽。

所以我最后把这部分拆成了几层:

Design Intent

Local Feasibility Test

Attribute Adjustment

Terrain Adaptation

Geometry Generation

我先判断参数在当前局部条件下还能不能用。然后再决定最后生成什么几何。

这套方法只服务于我现在这套 junction construction。它不是通用的道路优化模型。

1. 先保留设计意图,再允许局部调整

道路中心线先带有 road_level

我用它区分村庄主路、环路和不同等级的支路。再根据 road_level 得到目标宽度 exp_road_width

我没有直接把它当成最终路宽。我另外保留了一个 road_width

可以这样理解:

exp_road_width → 希望道路多宽
road_width → 当前条件下实际使用多宽

这两个值分开后,后面的路口处理和地形处理还能看到原来的目标值。系统也不会一直覆盖同一个 width

这一点对后面的逻辑很重要。

局部调整可以改结果,不能把原来的设计意图一起改掉。

pcgtown1_road_p1


2. 路口先判断空间,再决定 extent 和 width

问题最容易出现在 junction。

我会先围绕路口中心给所有连接道路做极角排序。这样,一个多岔路口就能按圆周顺序拆成几组相邻道路。

每一组相邻道路,我会看三个量:

  • 两条道路的目标宽度;
  • 它们之间的夹角;
  • 当前 junction extent。

路越宽,夹角越小,junction 展开的距离越短,两条路就越容易挤在一起。

当前实现会根据道路方向、宽度和可用距离做一个局部估计。

它主要回答一个问题:

按照现在这套路口构造方式,这两条道路还能不能保持目标宽度?

空间不够时,我先增加 junction extent。

道路等级已经表达了我想要的宽度关系。主路不能因为遇到一个锐角路口,就马上缩成支路的尺度。

当前处理顺序是:

保持目标路宽

空间不足

扩大 junction

受到短路段限制

再进一步限制 road_width

这里的估计方式是启发式的。

我更在意后面的数据处理方式。

每一组相邻道路都会给出一个 junction length 要求。最后用 max 汇总:

L_junction = max(local constraints)

一条道路也可能同时收到几个宽度限制。最后用 min 汇总:

w_road = min(local constraints)

网络里就是用 detail attribute 的 "max" 和 primitive attribute 的 "min" 来做这件事。

这部分对我来说比公式更重要。

它把几个局部判断变成了后面还能继续使用的属性:

local constraints

attribute reduction

stable network parameters

路口不再只是一个最后生成出来的 Mesh。它也会反过来影响道路的参数。

3. 地形不是最后的贴地步骤

平面关系处理完以后,还要处理地形。

只看中心线有没有贴住 Landscape 不够。

道路有宽度。横坡也会影响它。

所以我会在中心线上算出道路的横向方向。然后在左右两侧采样 HeightField 的 height

float lh = volumesample(1, "height", lpos);
float rh = volumesample(1, "height", rpos);

float cross_slope = abs(lh - rh) / max(maxw, 0.001);

然后根据横坡去调整道路宽度。

横坡变大,路宽就往较小的值靠。这个变化用连续插值来做。尺寸不会在某个坡度上突然跳一下。

这里最重要的是处理顺序:

Terrain 在道路 Surface 生成前,就已经开始影响道路尺度。

所以地形不只是最后拿来贴 Mesh。它会参与前面的参数调整。

道路首尾还要单独处理。

前面已经做过 junction matching。首尾尺寸不能再被地形处理改掉。

所以我会先保存首尾的 pscale,做完地形调整后再恢复。

它解决的是一个很直接的问题:

道路中段可以跟着地形变化。进入路口的位置要保留前面已经匹配好的尺度。

4. 尽量晚地生成 Polygon

做这套道路时,我慢慢形成了一个习惯:

能在 Curve 和 Attribute 层处理的问题,我就先不生成 Polygon。

前面的道路主要还是这些东西:

centerline
+
topology
+
attributes

等路口长度、道路宽度和地形调整都确定后,再去切 junction section 和普通道路段。

普通道路直接用 Ribbon Sweep。

路口要单独重建。道路边界会先延长。然后做 Intersection Analysis。最后再用整理后的曲线生成 junction surface。

Intersection Analysis 也会出问题。

少数情况下,它会得到零个交点,或者得到多个候选交点。

我给这里留了一个简单的 fallback。局部求交失败时,生成流程还能继续往下走。

这个 fallback 不保证每次都得到最理想的几何。

它解决的是稳定性问题:

程序化系统不仅要处理正常情况,也要考虑局部失败后还能不能继续生成。

我在这里更关心 generation robustness。我没有为每一种极端路口去做完整的计算几何处理。

最后

做这部分以前,我对道路生成的理解更接近:

Centerline
→ Width
→ Sweep

做完以后,我更习惯这样看它:

Design Intent
→ Local Conditions
→ Parameter Adjustment
→ Geometry

道路中心线只是输入。

最终结果还会受到路口空间、道路长度、目标宽度和地形的影响。

这套方法不想给出一种通用的道路几何模型。

我更关心一件事:

生成 Mesh 之前,先判断当前设计参数在这个位置还能不能继续用。

这也成为我后续处理类似程序化问题时会优先考虑的一种方式。

The most direct way to build procedural roads is simple. Start with centerlines. Give them width. Then use Sweep to generate the road surface.

In this village project, the main problem was not Sweep.

A centerline can work, while the road parameters around it may not.

Roads have different levels and different widths. Some junction angles are very small. Some road segments are short. They do not have enough space for the original road size. Terrain also affects road width when the cross slope becomes large.

So I divided the process into several layers:

Design Intent

Local Feasibility Test

Attribute Adjustment

Terrain Adaptation

Geometry Generation

I first check whether the current parameters can still work under local conditions. Then I decide what geometry to generate.

This method only serves the current junction construction in this project. It is not a general road optimization model.

1. Keep the Design Intent Before Local Adjustment

Each road centerline first gets a road_level.

I use it to separate village main roads, ring roads, and lower-level branches. Then I use road_level to define the target width, exp_road_width.

I do not use this value directly as the final road width. I also keep another attribute called road_width.

The two values can be understood like this:

exp_road_width → how wide the road should be
road_width → how wide the road is actually allowed to be

Because these values are separate, later junction and terrain operations can still access the original target width. The system does not keep overwriting the same width value.

This is important for the later logic.

Local adjustments can change the result. They should not erase the original design intent.

pcgtown1_road_p2


2. Check Junction Space Before Deciding Extent and Width

Junctions are where most problems appear.

I first sort all connected roads around the junction center by angle. This lets me split a multi-road junction into several neighboring road pairs.

For each neighboring pair, I look at three things:

  • the target width of both roads;
  • the angle between them;
  • the current junction extent.

Wider roads need more space. Smaller angles also need more space. A short junction extent makes the conflict worse.

The current system uses road direction, width, and available distance to make a local estimate.

It mainly asks one question:

Can these two roads keep their target width with the current junction construction?

If there is not enough space, I first increase the junction extent.

The road hierarchy already describes the width relationship I want. A main road should not immediately shrink to the size of a branch road just because it enters a sharp junction.

The current order is:

Keep target road width

Not enough space

Increase junction extent

Limited by short road segments

Further reduce road_width

The estimate here is heuristic.

I care more about how the results are handled afterward.

Each neighboring road pair produces a junction length requirement. The final junction length uses max:

L_junction = max(local constraints)

A road can also receive several width limits. These are combined with min:

w_road = min(local constraints)

The Houdini network uses "max" on a detail attribute and "min" on a primitive attribute for this step.

This part matters more to me than the exact formula.

It turns several local checks into attributes that can continue through the network:

local constraints

attribute reduction

stable network parameters

The junction is no longer only a Mesh generated at the end. It can also change the parameters of the connected roads.

3. Terrain Is Not Only a Final Projection Step

After the planar road relationships are handled, the terrain still needs to be considered.

Checking whether the centerline follows the Landscape is not enough.

Roads have width. Cross slope also matters.

So I calculate the side direction of the road along the centerline. Then I sample the HeightField height on both sides:

float lh = volumesample(1, "height", lpos);
float rh = volumesample(1, "height", rpos);

float cross_slope = abs(lh - rh) / max(maxw, 0.001);

I then use the cross slope to adjust the road width.

As the cross slope increases, the road moves toward a smaller width. The change uses continuous interpolation. The width does not suddenly jump at one slope value.

The important part here is the timing:

Terrain starts affecting road scale before the road Surface is generated.

Terrain is not only used to place the final Mesh on the ground. It also affects road parameters earlier in the process.

The road endpoints need separate handling.

The junction matching has already been done. The endpoint scale should not be changed again by terrain adaptation.

So I save the endpoint pscale values first. I restore them after the terrain adjustment.

This solves a simple problem:

The middle of the road can change with the terrain. The parts entering the junction should keep the scale that was already matched.

4. Generate Polygon Geometry as Late as Possible

While building this road system, I gradually developed one rule:

If a problem can be handled with Curves and Attributes, I try not to generate Polygons yet.

During the earlier stages, the road mostly remains as:

centerline
+
topology
+
attributes

After the junction length, road width, and terrain adjustment are stable, I split the road into junction sections and normal road sections.

Normal road sections use Ribbon Sweep.

Junctions are rebuilt separately. The road boundaries are extended first. Then I use Intersection Analysis. The processed curves are then used to build the junction surface.

Intersection Analysis can also fail.

In some cases, it returns no intersection point. In other cases, it returns several candidate points.

I keep a simple fallback for these cases. If a local intersection fails, the generation chain can still continue.

This fallback does not guarantee the best geometry in every case.

It solves a stability problem:

A procedural system needs to handle normal cases. It also needs a way to continue after a local failure.

Here I care more about generation robustness. I do not try to build a complete computational geometry solution for every extreme junction case.

Final Thoughts

Before building this part, I thought about road generation more like this:

Centerline
→ Width
→ Sweep

After building it, I started to think about it like this:

Design Intent
→ Local Conditions
→ Parameter Adjustment
→ Geometry

The road centerline is only the input.

The final result also depends on junction space, road length, target width, and terrain.

This method is not meant to define a general road geometry model.

I care more about one thing:

Before generating the Mesh, first check whether the current design parameters can still work at that location.

This is also one of the approaches I now consider first when working on similar procedural problems.