CityEngine道路规则库实战:从CGA规则到参数化路网生成
2026/9/11 21:41:06 网站建设 项目流程

简介:在数字孪生与城市三维建模场景中,道路作为城市骨架的底层要素,其建模效率和可调性直接影响项目交付质量。传统手工建模难以应对大规模路网修改需求,而基于CityEngine的CGA规则提供了一种参数化生成道路的解决路径。通过将路网Graph转化为Segment与Junction形状,再由规则驱动挤出、切分和材质赋予,即可实现道路横断面、交叉口、标线等细节的自动生成。这种程序化建模方式不仅大幅降低修改成本,还能保证多路段风格统一。该技术广泛适用于三维辅助规划、智慧城市视觉底座、方案快速比选等场景。本文以道路规则库为主线,拆解CGA流转逻辑、横断面设计、路口处理与性能优化要点,帮助读者从零构建一套可复用、易调参的城市道路生成体系。 几年前我第一次在CityEngine里"认真"建一条道路,两公里长的主干道,机动车道、绿化隔离带、非机动车道、人行道、路缘石一层层往上叠,模型拉了整整一下午。结果甲方第二天说路网骨架要微调,我当场血压拉满——所有跟道路相关的几何体全部要动。那之后我彻底明白,CityEngine的玩法从来不是手工建模,而是用规则去驱动路网生成。今天这篇,就把我沉淀下来的CityEngine道路规则库拆开讲讲,从CGA的流转逻辑、道路横断面细节拆解,到交叉口的处理、参数化设计和实战中容易踩的坑,一次说清楚。

这套内容适合谁?如果你在折腾数字孪生、三维辅助规划、城市级场景搭建,或者单纯想用CityEngine把路网数据变成有细节的模型,那这篇可以帮你理清思路,少走不少弯路。

1. 为什么我放弃手工建路:道路规则库解决的真实痛点

先聊点背景。CityEngine的建模思路和传统建模软件完全不一样。传统DCC软件里,一条路就是一条路,你画一条样条线,挤出、倒角、贴图,完事。但在城市级场景里,路网往往有成百上千条街道,如果每一条都靠手动处理,工作量是灾难性的。更麻烦的是,道路不是独立存在的——一条路要和人行道连通,要和交叉口衔接,要在转弯处生成平滑的路缘石,这些几何关系在手工建模时很容易顾此失彼。

道路规则库做的事情,本质上是把"路网Graph"转成"带细节的三维道路模型"。CityEngine里会先生成一个路网拓扑结构——街道中心线(Street Graph),然后每条边会生成一个Segment shape,每个节点会生成一个Junction shape,这些shape会进入各自的CGA规则中,由规则负责挤出、切割、贴材质。换句话说,你不需要画每一条路,只需要告诉规则库"这条路多宽、几条车道、有没有中央隔离带",剩下的几何生成交给规则去算。

这种思路带来的直接好处有三点:

  • 修改成本极低。道路宽度从24米调到30米,改一个attr参数,重新生成,全场景同步更新。手工模型要改的话,每条路挨个拖点。
  • 质量稳定。规则是程序化的,每条路生成出来都严格符合你预设的横断面逻辑,不会出现某条路人行道宽、某条路人行道窄这种人工误差。
  • 可批量复用。一套规则库可以投到不同的路网数据上,换一个城市、换一个区域,只要路网Graph还在,生成逻辑完全一致。

所以当时我从"一条路一条路画"转向"搭规则库"之后,基本就再也回不去了。CGA这套东西刚开始有点门槛,但一旦摸透几个核心逻辑,收益非常直接。

2. CGA规则在路网上的流转逻辑:从Graph到Segment再到Junction

既然要搭道路规则库,第一件事不是急着写split,而是先理解CGA是怎么被路网调用的。CityEngine的CGA规则有几个固定的入口,对应不同类型的shape:

  • Street规则:作用于路网的边(Segment),也就是每一条街道。
  • Junction规则:作用于路网的节点,也就是交叉口。
  • Lot规则:作用于道路围合出的地块,建筑生成走这条。
  • Block规则:作用于街区,常用于更粗粒度的城市分区逻辑。

写道路规则库,核心盯住Street和Junction就够。

我实际项目里的规则文件,通常会先定义一堆attr作为全局参数,放文件顶部。CGA的attr有点类似编程语言里的常量,也可以是"公开给外部调用的参数"。在CityEngine的Inspector面板里,你可以直接看到这些attr并实时调整,这是规则库参数化交互的基础。

下面是一段我一直沿用的Street规则骨架,先看整体结构,细节后面拆:

version "2019.1" @Group("道路参数") @Order(1) attr streetWidth = 24 @Group("道路参数") @Order(2) attr sidewalkWidth = 4 @Group("道路参数") @Order(3) attr curbHeight = 0.15 @Group("车道参数") @Order(1) attr laneWidth = 3.5 @Group("车道参数") @Order(2) attr laneCount = 4 @Group("车道参数") @Order(3) attr markingWidth = 0.15 @StartRule Street --> s(scope.sx, 0, streetWidth) t(0, 0, -streetWidth / 2) split(z) { sidewalkWidth: Sidewalk | streetWidth - 2 * sidewalkWidth: Carriageway | sidewalkWidth: Sidewalk } Sidewalk --> extrude(world.y, curbHeight) setupProjection(0, scope.xy, 1, 1) set(material.colors.diffuse, sidewalkTexture) Carriageway --> s('1, 0, '1) split(z) { laneWidth: AsphaltLane | markingWidth: LaneMarking }*

这段规则看起来简单,但里面有几个非常关键的CGA知识点。

第一,Street --> s(scope.sx, 0, streetWidth)。这里是把街道shape的宽度设置成streetWidth,同时y方向高度设为0,因为初始的街道shape是一个平面,不需要高度。scope.sx是当前shape在局部x方向的尺寸,在路网Segment里,x方向基本就是街道的走向。

第二,split(z)。split是CGA里最常用的切分命令,括号里的z表示沿着shape本地坐标系的z轴方向切。在道路Segment中,z方向通常对应街道的横断面方向。所以split(z)就是在切道路横断面:先切出两侧人行道,再切中间车行道。如果你生成的街道shape坐标轴向和预期不一致,把z换成x试试,这是CGA里常见的"轴向调理"问题,不是bug,多试几次就有感觉了。

第三,Carriageway里的split用了repeat语法,split(z) { laneWidth: AsphaltLane | markingWidth: LaneMarking }*,后面这个*号表示在当前剩余的空间里循环切分,直到剩余尺寸不足以容纳一次完整的分割为止。这个写法在车道数量不固定、但每条车道宽度固定时非常好用,你不用写死"四车道"还是"六车道",路网里某条路的宽度变了,车道数量会自动适应。

有一点要特别提醒:split里的字符|是分隔符,不是CGA的"或"逻辑。CGA的"或"逻辑用caseelse实现。新手经常在这儿绕晕。

这段骨架跑通之后,你就有了一个最小的道路规则库雏形。接下来要做的,是把每条路的横断面细节填进去,让道路从"几块平板"变成"有层次的城市肌理"。

3. 道路横断面拆解:车道、标线、人行道与路缘石的规则写法

道路横断面是道路规则库的核心表达。一条完整的城市道路,从中间往两侧大概由这几个部分组成:中央隔离带(如果有)、机动车道、车道分界线、路缘带、非机动车道、机非隔离带、人行道、盲道、路缘石。数字孪生项目里当然不用每一步都按施工图来,但几个关键层次一定要有,否则模型一眼假。

我通常把横断面拆成若干个子规则处理,每个子规则只负责一个组成部分。这样逻辑清晰,后续也好维护。先看车行道:

Carriageway --> s('1, 0, '1) split(z) { laneWidth : AsphaltLane | markingWidth : LaneMarking }* AsphaltLane --> extrude(world.y, 0.1) setupProjection(0, scope.xy, 4, 4) set(material.colors.diffuse, asphaltTexture) LaneMarking --> s('1, 0, markingWidth) t(0, 0, -markingWidth / 2) extrude(world.y, 0.02) set(material.colors.r, 0.95) set(material.colors.g, 0.95) set(material.colors.b, 0.95) set(material.colors.diffuse, "#f2f2f2")

AsphaltLane里,先压扁为平面,然后挤出0.1米的厚度,这样沥青路面在场景中不是一张薄片,不会出现从侧面看消失的问题。setupProjection(0, scope.xy, 4, 4)是设置UV投射,4和4代表纹理在x、y方向各重复4次,避免一张贴图拉伸到整条路导致纹理糊掉。很多新手路面贴图全是花斑,就是因为忘了设置纹理投射参数。

LaneMarking里,我把标线宽度缩到markingWidth,再整体沿z轴平移负方向一半,让标线居中。接着挤出0.02米的厚度,并设置白色材质。一条标线在宏观场景里其实很细小,但有了这个厚度和颜色,摄像机贴近地面时能看出细节,对场景表现力帮助很大。

人行道的规则稍微复杂一点,因为要处理路缘石的竖面:

Sidewalk --> extrude(world.y, sidewalkHeight) setupProjection(0, scope.xy, 1, 1) set(material.colors.diffuse, sidewalkTexture)

这里的sidewalkHeight我习惯设为0.12到0.15米。注意人行道挤出后,侧面和顶面会共用材质贴图,如果纹理的方向不对,可以在侧面上单独设置材质,比如:

Sidewalk --> extrude(world.y, sidewalkHeight) comp(f) { top : SidewalkTop | side : CurbFace } CurbFace --> set(material.colors.diffuse, curbTexture) setupProjection(0, scope.yz, 1, 1)

comp(f)是CGA里的组件分割命令,top对应顶面,side对应侧面。这种"顶面和侧壁分材质"的做法,在路缘石、中央隔离带、花坛边缘等场景里非常常用。否则你会在渲染结果里看到人行道的侧面也被贴了砖纹贴图,视觉上会很怪。

中央隔离带可以做成一个独立的可开关参数。很多城市主干道都有2到4米宽的中央分隔带,里面要么做绿植,要么做防撞护栏。我在规则库里会定义一个centerIslandWidth参数,默认0表示没有隔离带,大于0时在车行道中间插入隔离带逻辑:

Carriageway --> case centerIslandWidth > 0: split(z) { laneWidth: AsphaltLane | markingWidth: LaneMarking | centerIslandWidth / 2: CenterIslandHalf | ~1: CenterIslandEdge | centerIslandWidth / 2: CenterIslandHalf }* else: split(z) { laneWidth: AsphaltLane | markingWidth: LaneMarking }*

这里用case centerIslandWidth > 0判断是否启用中央隔离带。CGA的case后面可以接数值比较,多个分支用else兜底,写法上接近传统编程语言。隔离带在实际表现中,我更倾向用一条窄绿化带加两侧路缘石来表示,而不是真的种一排树模型,因为路网规模大时,大量植物实例会严重拖累性能。

横断面细节拆得越细,规则文件越长,但这恰恰是规则库的价值——你把城市道路的类型抽象成参数和分支,每一条生成出来的路都符合同一套逻辑语言。到后面你换一个城市的路网数据,跑的还是这套规则,效果却很"本地化",因为只要调几个参数就能改变整个街道的性格。

4. 十字路口和边界连接:Junction规则中的细节与取舍

道路与道路相交的地方,才最考验规则库的功力。CityEngine里,路网节点生成的shape会自动进入Junction规则。一个路口如果就是一块平地,那当然很简单,但实际项目中,交叉口涉及转弯半径、人行横道、停止线、导流岛,还有路口与道路的衔接平滑度。

Junction规则最简单的版本是这样:

Junction --> s('1, 0, '1) extrude(world.y, 0.05) setupProjection(0, scope.xy, 4, 4) set(material.colors.diffuse, asphaltTexture)

这个版本的效果就是一块和道路同高的沥青平板,适合快速验收和数据调试。但真实场景里路口不能这么糊弄,至少要处理两个问题。

第一个问题是转角的平滑。CityEngine中街道中心的转角圆角通常不是在CGA里做的,而是在Graph层面设定。你选中路网的节点,在属性面板里调整cornerTypecornerRadius,可以让路网的交点转弯半径变大。Segment shape进入Street规则后,道路几何会沿着这个转弯路径生成,道路本身也就有了平滑的转弯。如果路口的Junction规则和Street规则各自是孤立生成的,你会发现转弯处有一块几何重叠或缝隙。我常用的办法是让Junction规则先根据shape.adjacentEdges判断与几条道路相连,然后针对不同连接形态生成对应的人行横道和停止线。

第二个问题是人行横道。斑马线这种东西,纯用CGA几何去生成非常痛苦——你要在路口范围内找方向、定宽度、画条纹。我实际项目里的做法是用贴图代替几何。在Junction规则的顶面专门投影一张人行横道贴图,配合透明通道,在路面上"画"出斑马线。模型量小,渲染也没压力。

Junction --> s('1, 0, '1) extrude(world.y, 0.05) comp(f) { top : JunctionTop } JunctionTop --> case (hasCrosswalk): setupProjection(0, scope.xy, 1, 1) set(material.colors.diffuse, crosswalkTexture) set(material.opacity, 1) else: setupProjection(0, scope.xy, 4, 4) set(material.colors.diffuse, asphaltTexture)

这里的hasCrosswalk可以定义成一个attr,也可以在生成路网时通过规则属性值注入。如果你希望在不同路口做差异化处理,可以把hasCrosswalk和路口等级挂钩——主干道交叉口有斑马线,支路交叉口没有。这样规则会显得更"智能"。

还有一个容易忽略的细节是交叉口地面与道路路面的高差衔接。如果Street规则中道路有0.1米的挤出厚度,Junction规则里如果没有同样挤出,那你会在路口看到一条"台阶线"。我踩过这个坑,最后统一约定:Street的沥青面挤出高度和Junction的挤出高度保持一致。这个约定要写进规则库的设计文档里,否则三个月后你自己都会忘。

Circle环岛的处理也值得一提。CityEngine路网本身支持环岛生成,环路节点会作为特殊的Junction shape进入规则。我的做法是识别环岛节点后套用独立的RingRoad规则,让环岛内侧单独生成一块绿地或硬质铺装,外侧生成环形车行道。具体的识别方法是在Junction规则里判断geometry.isCircular之类的属性,如果版本里取不到,可以在Graph层的属性面板手动为环岛节点打标签,然后再用attr接过来。

路口规则写多了之后,我最大的体会是:不要试图用一个万能规则覆盖所有路口形态。程序化建模追求的是"大多数情况自动处理、特殊路口手动覆盖",所以我的规则库里经常写这么一行:

Junction --> case (fakeJunctionExists): FakeJunction else: GenericJunction

预留一个手工覆盖的口子,当你遇到复杂畸形路口时,直接导一个模型进去替换,比在CGA里死磕几何要高效得多。

5. 规则库的参数化设计:一套规则适配不同城市风格

道路规则库做到能跑通路面、路口、标线之后,就要往"可配置"的方向发展。因为我很快发现一个问题——项目A是新城区的宽马路,双向八车道加宽绿化带;项目B是旧城区的窄街道,路幅不到12米,两侧还是小店招牌。如果每换一个项目就改一堆规则文件,那和手工建路的效率有什么区别?

参数化是这个阶段的核心目标。CGA的attr在设计时就要分好组、排好序、给好取值范围。这样在CityEngine的Inspector面板里,别人一看到的就是一个干干净净的参数面板,而不是一堆底层代码。

我常用的属性分组这样写:

@Group("整体路幅") @Order(1) @Range(6, 80) attr streetWidth = 24 @Group("整体路幅") @Order(2) @Range(0, 8) attr centerIslandWidth = 2 @Group("人行系统") @Order(1) @Range(1, 10) attr sidewalkWidth = 4 @Group("人行系统") @Order(2) @Range(0, 0.5) attr sidewalkHeight = 0.15 @Group("车道") @Order(1) @Range(2.8, 4.5) attr laneWidth = 3.5 @Group("车道") @Order(2) @Range(0, 12) attr laneCount = 6

@Group负责在面板里分组,@Order控制排序,@Range限定参数范围。这样一个道路规则文件暴露出来的参数就是"路幅多宽、几车道、人行道多宽、有没有隔离带"这种对规划师友好的概念,而不是那些split里头的技术细节。

更进一步的参数化,是做风格预设。CGA里可以用const定义一组预设配置,也可以把一套参数保存成.cga文件里的preset。我的做法是在规则文件里定义几套命名配置:

RoadStyle --> case style == "urban_main": streetWidth = 40 centerIslandWidth = 3 sidewalkWidth = 5 laneWidth = 3.5 laneCount = 8 case style == "urban_branch": streetWidth = 20 centerIslandWidth = 0 sidewalkWidth = 4 laneWidth = 3.5 laneCount = 4 else: streetWidth = 12 centerIslandWidth = 0 sidewalkWidth = 3 laneWidth = 3.25 laneCount = 2

在Inspector里,你只要切一个style下拉框,整个场景的道路规格就会整体切换。这个功能在给甲方出多方案对比的时候非常好使,"窄路密网"和"宽马路大尺度"两种方案,一键切换,方案汇报直接变成现场演示。

不过要提醒一句:参数化不是把所有东西都做成参数。有些东西做成参数反而增加理解负担。我自己掌握的原则是——影响道路性格的特征做成参数,纯技术性的细节写死在规则里。比如车道宽度、人行道宽度、标线样式这种对城市风貌影响明显的东西必须暴露;至于沥青材质贴图的投射次数,这种改了一百次也没有视觉质变的细节,直接写死就好。

参数化设计还会引出一个资产管理问题。道路规则库通常需要配套一批贴图资源:沥青纹理、人行道砖纹、路缘石灰浆、斑马线贴图、隔离带绿化贴图等。建议在项目目录里建立一个规范的资产文件夹:

rules/ street_rule.cga junction_rule.cga common_utils.cga assets/ textures/ asphalt/ sidewalk/ crosswalk/ markings/ models/ pole/ barrier/

CGA里引用贴图时,用相对路径,不要用绝对路径。否则把项目文件拷贝到另一台机器上,贴图全部失效,这是最让人抓狂的"移机事故"。

6. 实测中反复踩的坑与性能优化建议

规则库写多了,自然攒了一批坑。挑几个高频的说说。

第一个坑是单位混乱。CityEngine的默认世界单位是米,但很多从CAD导入的底图数据可能是毫米单位或者度单位(WGS84经纬度)。路网Graph宽度是24还是24000,完全取决于数据源。解决办法是导入数据时统一做坐标转换,或者在attr里用一个补偿系数兜底。我见过项目组因为单位不对,Road生成出几百米宽的路面,还一脸懵。

第二个坑是split方向判断失误。前面提到过,Segment shape的局部轴向并不总是统一的,尤其是从复杂Graph生成的道路,某些路段的shape局部z轴可能和其他路段不一致。表现就是大部分路生成正常,个别路段车道方向跑偏。排查这种问题,最有效的办法是开启CityEngine的Debug: Show Scope可视化。在Viewport里把shape的scope坐标轴显示出来,一眼就能看出哪条路的轴向有问题,再单独处理。

第三个坑是超量细分导致场景卡死。split有一个maxIteration概念,如果在repeat split里没有限制次数,碰到一条特别长的路,迭代生成的面片数量会非常惊人。CGA里可以给规则加一个兜底控制:

@Hidden attr maxLaneIterations = 12 Carriageway --> case laneCount == 0: s('1, 0, '1) case laneCount > maxLaneIterations: print("车道数超限,截断为" + maxLaneIterations) s('1, 0, '1) else: split(z) { laneWidth: AsphaltLane | markingWidth: LaneMarking }*

别看这么简单,加一行截断判断,能在场景出错时救你很多时间。CGA里有print()函数,用来输出调试信息,规则卡住的时候多打印几个变量,比盲猜高效得多。

第四个坑是贴图UV漂移。这个问题在长距离道路上特别常见。一条几公里的路,如果贴图投射坐标没有和世界坐标对齐,路面纹理会在某个节点处突然跳变,相当于一张贴图被"折断"了。解决办法是使用projectUV(0, world.xy, 1, 1)之类的世界坐标投射,而不是依赖shape自身的局部坐标:

AsphaltLane --> extrude(world.y, 0.1) projectUV(0, world.xy, laneWidth, laneWidth) set(material.colors.diffuse, asphaltTexture)

这里world.xy表示在世界坐标系的x-y平面上投射,右侧两个参数是贴图在x、y方向的缩放尺寸,我习惯直接取loneWidth,这样每一段路面上的纹理大小看起来都均匀,不会出现某段路纹理大、某段路纹理小的现象。

第五个坑,也是性能问题——几何实例化(GI)没开或不会用。CityEngine生成的模型,导出到其他软件或引擎里,几何体数量往往非常大。解决办法之一是把道路两侧的路灯、护栏、井盖这类重复物件用i命令做实例化:

StreetLight --> i("/assets/models/street_light.fbx")

只要规则里用了i("模型路径"),CityEngine生成的模型就自动以实例引用方式存在,导出的场景文件里这些重复模型不会产生大量独立几何体。我实测过,一条5公里的道路,两边每隔30米一根路灯,不实例化时导出模型可能有几百万面,实例化之后只有一份模型数据加几百个引用,性能差别是按数量级算的。

另外关于导出,CityEngine直接导出的模型,很多时候贴图路径是相对工程文件的。如果要把模型交给渲染团队,记得勾选"复制贴图到输出目录",或者用FBX/glTF格式时检查贴图是否成功嵌入。否则对方打开模型全是灰模,然后跑来问你是不是模型坏了,很浪费沟通成本。

还有一点我特别想说的经验是:先做一个极小的规则原型跑通,再往里面加细节。写CGA规则很像写代码,一个逻辑错误可能要到生成阶段才暴露。我见过太多人一上来就写几百行规则,想一步到位做一个"完美"的道路系统,结果运行报错根本定位不了。正确节奏是:先写一个只含Street和Junction两个规则的骨架,确保路网能生成,再逐步加入人行道、标线、斑马线、隔离带。每加一个功能,跑一次生成,确认效果,再往下走。这样你的规则库质量是可控的,每一层都经过验证。

最后,关于规则库的长期维护,我建议在每条规则文件顶部写清楚版本标记和适用范围。CGA本身没有强制要求版本号,但CityEngine的版本升级偶尔会带来语法兼容问题,养成写version "2019.1"这种标注的习惯,至少可以帮你定位是不是版本导致的语法错误。如果你和我一样,经常要同时维护多个项目的道路规则库,建议把通用的道路横断面逻辑抽成一个common文件,然后各个项目里用import引进来,只覆盖本项目特有的参数和特殊处理。这样一套通用规则库打底,配合项目级定制,既保证跨项目的复用性,又不会让每个项目都绑死在同一套规则上。

做规则库这件事,比起"写一条能让某段路好看"的规则,更值钱的是"写一套能让所有路都协调且可调"的规则系统。参数暴露得干净、分支处理得克制、错误拦截得及时,等你在给甲方演示时一键切换方案,就知道前面这些较真都值了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询