Procedural Terrain and Collision

KangEngine can generate tiled height-field terrain in Python, render it as one continuous mesh, and use the same height samples for PhysX collision.

The complete example mixes stairs, slopes, waves, random ground, and discrete obstacles:

python ./python/examples/view_procedural_terrain.py

It also drops dynamic spheres and boxes onto the terrain to verify that the rendered surface and PhysX heightfield agree. Press R to reset the bodies.

Complete source: view_procedural_terrain.py
  1"""Generate a procedural terrain height field in Python and render it as mesh."""
  2
  3from __future__ import annotations
  4
  5import argparse
  6
  7import numpy as np
  8
  9import kangengine as ke
 10from kangengine import imgui, keys, terrain
 11
 12
 13TERRAIN_TYPES = ("stairs", "slope", "pyramid", "wave", "random", "obstacles")
 14
 15
 16class ProceduralTerrainViewer(ke.App):
 17    def __init__(
 18        self,
 19        *,
 20        terrain_type: str = "mixed",
 21        rows: int = 3,
 22        cols: int = 3,
 23        tile_width: int = 96,
 24        tile_length: int = 96,
 25        horizontal_scale: float = 0.05,
 26        vertical_scale: float = 0.005,
 27        backend: str = "cpp",
 28        seed: int = 7,
 29        collision_test: bool = True,
 30        test_bodies_per_type: int = 10,
 31    ):
 32        super().__init__()
 33        self.terrain_type = terrain_type
 34        self.rows = int(rows)
 35        self.cols = int(cols)
 36        self.tile_width = int(tile_width)
 37        self.tile_length = int(tile_length)
 38        self.horizontal_scale = float(horizontal_scale)
 39        self.vertical_scale = float(vertical_scale)
 40        self.backend = backend
 41        self.seed = int(seed)
 42        self.collision_test = bool(collision_test)
 43        self.test_bodies_per_type = int(test_bodies_per_type)
 44
 45    def setup(self):
 46        self.standard_materials = self.create_standard_materials()
 47        self.set_light_direction(ke.Vec3(-0.35, 0.82, -0.45))
 48        self.set_light_intensity(1.25)
 49        self.set_light_ambient(ke.Vec3(0.32, 0.32, 0.32))
 50
 51        self.rng = np.random.default_rng(self.seed)
 52        self.grid = terrain.TerrainGrid(
 53            self.rows,
 54            self.cols,
 55            self.tile_width,
 56            self.tile_length,
 57            horizontal_scale=self.horizontal_scale,
 58            vertical_scale=self.vertical_scale,
 59        )
 60        self.grid.fill(self._generate_tile)
 61        self.mesh = self.grid.to_mesh(up_axis=ke.UpAxis.Y, backend=self.backend)
 62
 63        self.material = self.create_phong_material(
 64            ambient=ke.Vec3(0.10, 0.12, 0.10),
 65            diffuse=ke.Vec3(0.27, 0.28, 0.27),
 66            specular=ke.Vec3(0.06, 0.06, 0.06),
 67            shininess=12.0,
 68        )
 69        self.view = self.scene.add_mesh("/procedural_terrain", self.mesh, self.material)
 70        self.view.set_double_sided(False)
 71
 72        self.physics = None
 73        self.collision_added = False
 74        self.test_bodies = []
 75        if self.collision_test:
 76            physics_config = ke.physics.PhysicsConfig.y_up()
 77            self.physics = ke.physics.PhysicsWorld(physics_config)
 78            self.timing = self.configure_timing(
 79                ke.SimulationTimingConfig.from_dt(
 80                    physics_dt=physics_config.dt,
 81                    fixed_dt=physics_config.dt,
 82                    render_hz=60.0,
 83                )
 84            )
 85            self.set_simulation_hotkeys_enabled(True)
 86            heights = np.ascontiguousarray(self.grid.height_meters(), dtype=np.float32)
 87            self.collision_added = self.physics.add_heightfield(
 88                heights.reshape(-1),
 89                self.grid.width,
 90                self.grid.length,
 91                horizontal_scale=self.horizontal_scale,
 92                up_axis=ke.UpAxis.Y,
 93                center=True,
 94                register_as_ground=True,
 95                material=ke.physics.PhysicsMaterialDesc([1.0, 1.0, 0.0]),
 96            )
 97            if self.collision_added:
 98                self._create_collision_test_bodies()
 99
100        self._setup_camera()
101        print(
102            "Procedural terrain loaded: "
103            f"type={self.terrain_type} tiles={self.rows}x{self.cols} "
104            f"grid={self.grid.width}x{self.grid.length} "
105            f"vertices={len(self.mesh.vertices)} "
106            f"triangles={len(self.mesh.indices) // 3} "
107            f"collision={'yes' if self.collision_added else 'no'} "
108            f"test_bodies={len(self.test_bodies)}"
109        )
110
111    def pre_update(self):
112        if self.was_key_pressed(keys.R):
113            self._reset_collision_test_bodies()
114
115    def fixed_update(self, fixed_dt):
116        if self.physics:
117            self.physics.step()
118
119    def pre_render(self):
120        if self.physics:
121            self._sync_collision_test_bodies()
122
123    def render(self):
124        imgui.begin("Procedural Terrain")
125        imgui.text(f"type: {self.terrain_type}")
126        imgui.text(f"tiles: {self.rows} x {self.cols}")
127        imgui.text(f"tile: {self.tile_width} x {self.tile_length}")
128        imgui.text(f"grid: {self.grid.width} x {self.grid.length}")
129        imgui.text(f"backend: {self.backend}")
130        imgui.text(f"horizontal scale: {self.horizontal_scale:.3f}")
131        imgui.text(f"vertical scale: {self.vertical_scale:.4f}")
132        imgui.text(f"vertices: {len(self.mesh.vertices):,}")
133        imgui.text(f"triangles: {len(self.mesh.indices) // 3:,}")
134        imgui.text(
135            f"collision test: {'on' if self.collision_test and self.collision_added else 'off'}"
136        )
137        imgui.text(f"test bodies: {len(self.test_bodies)}")
138        if self.test_bodies:
139            paused = self.is_simulation_paused()
140            changed, paused = imgui.checkbox("pause physics", paused)
141            if changed:
142                self.set_simulation_paused(paused)
143            imgui.text("Enter: play/pause    Space: pause/step")
144            if imgui.button("reset test bodies"):
145                self._reset_collision_test_bodies()
146        imgui.end()
147
148    def _generate_tile(
149        self, t: terrain.SubTerrain, tile_row: int, tile_col: int
150    ) -> terrain.SubTerrain:
151        kind = self._tile_type(tile_row, tile_col)
152        if kind == "stairs":
153            terrain.stairs_terrain(t, step_width=0.35, step_height=0.08)
154        elif kind == "slope":
155            direction = -1.0 if (tile_row + tile_col) % 2 else 1.0
156            terrain.sloped_terrain(t, slope=direction * 0.18)
157        elif kind == "pyramid":
158            terrain.pyramid_sloped_terrain(t, slope=0.28, platform_size=1.2)
159        elif kind == "wave":
160            terrain.wave_terrain(t, num_waves=3.0, amplitude=0.35)
161        elif kind == "random":
162            terrain.random_uniform_terrain(t, -0.05, 0.05, step=0.01, rng=self.rng)
163        elif kind == "obstacles":
164            terrain.discrete_obstacles_terrain(
165                t,
166                max_height=0.25,
167                min_size=0.2,
168                max_size=0.7,
169                num_rects=80,
170                platform_size=1.0,
171                rng=self.rng,
172            )
173        else:
174            raise ValueError(f"unknown terrain type: {kind}")
175        return t
176
177    def _tile_type(self, row: int, col: int) -> str:
178        kind = self.terrain_type.lower()
179        if kind != "mixed":
180            return kind
181        return TERRAIN_TYPES[(row * self.cols + col) % len(TERRAIN_TYPES)]
182
183    def _create_collision_test_bodies(self):
184        if self.physics is None or not self.collision_added:
185            return
186
187        count = max(0, self.test_bodies_per_type)
188        if count == 0:
189            return
190
191        sphere_material = self.create_phong_material(
192            ambient=ke.Vec3(0.12, 0.06, 0.03),
193            diffuse=ke.Vec3(0.95, 0.32, 0.08),
194            specular=ke.Vec3(0.08, 0.08, 0.08),
195            shininess=20.0,
196        )
197        box_material = self.create_phong_material(
198            ambient=ke.Vec3(0.03, 0.07, 0.12),
199            diffuse=ke.Vec3(0.12, 0.42, 0.95),
200            specular=ke.Vec3(0.08, 0.08, 0.08),
201            shininess=20.0,
202        )
203
204        radius = max(0.08, self.horizontal_scale * 5.0)
205        half = max(0.08, self.horizontal_scale * 4.0)
206        sphere_mesh_data = ke.geometry.create_sphere_data(radius, 20, 10)
207        box_mesh_data = ke.geometry.create_box_data(half * 2.0, half * 2.0, half * 2.0)
208
209        for index in range(count):
210            pos = self._random_spawn_position(index, count * 2)
211            actor = self.physics.create_dynamic_sphere(
212                radius,
213                [pos.x, pos.y, pos.z],
214                [0.0, 0.0, 0.0, 1.0],
215                1.0,
216            )
217            view = self.scene.add_mesh(
218                f"/collision_test/sphere_{index}", sphere_mesh_data, sphere_material
219            )
220            self.test_bodies.append(("sphere", actor, view, radius))
221
222        for index in range(count):
223            pos = self._random_spawn_position(count + index, count * 2)
224            actor = self.physics.create_dynamic_box(
225                [half, half, half],
226                [pos.x, pos.y, pos.z],
227                [0.0, 0.0, 0.0, 1.0],
228                1.0,
229            )
230            view = self.scene.add_mesh(
231                f"/collision_test/box_{index}", box_mesh_data, box_material
232            )
233            self.test_bodies.append(("box", actor, view, half))
234
235        self._sync_collision_test_bodies()
236
237    def _random_spawn_position(self, index: int, total: int):
238        width = (self.grid.length - 1) * self.horizontal_scale
239        length = (self.grid.width - 1) * self.horizontal_scale
240        margin = max(width, length) * 0.12
241        x = self.rng.uniform(-width * 0.5 + margin, width * 0.5 - margin)
242        z = self.rng.uniform(-length * 0.5 + margin, length * 0.5 - margin)
243        max_height = float(np.max(self.grid.height_meters()))
244        drop_span = max(2.0, max(width, length) * 0.25)
245        y = max_height + 1.0 + drop_span * (0.35 + 0.65 * index / max(total - 1, 1))
246        return ke.Vec3(float(x), float(y), float(z))
247
248    def _reset_collision_test_bodies(self):
249        total = len(self.test_bodies)
250        for index, (_kind, actor, _view, _size) in enumerate(self.test_bodies):
251            pos = self._random_spawn_position(index, total)
252            actor.set_root_state(
253                [pos.x, pos.y, pos.z],
254                [0.0, 0.0, 0.0, 1.0],
255                [0.0, 0.0, 0.0],
256                [0.0, 0.0, 0.0],
257            )
258        self._sync_collision_test_bodies()
259
260    def _sync_collision_test_bodies(self):
261        for _kind, actor, view, _size in self.test_bodies:
262            pos = actor.get_root_position()
263            rot = actor.get_root_rotation()
264            view.prim.set_local_translation(
265                ke.Vec3(float(pos[0]), float(pos[1]), float(pos[2]))
266            )
267            view.prim.set_local_rotation(
268                ke.Quat(float(rot[3]), float(rot[0]), float(rot[1]), float(rot[2]))
269            )
270
271    def _setup_camera(self):
272        camera = self.get_camera()
273        camera.set_near_plane(0.02)
274        camera.set_far_plane(1000.0)
275        camera.set_fov(55.0)
276        radius = max(self.grid.width, self.grid.length) * self.horizontal_scale
277        target = ke.Vec3(0.0, 0.0, 0.0)
278        camera.set_target_pos(target)
279        camera.set_camera_pos(
280            target + ke.Vec3(radius * 0.45, radius * 0.55, radius * 0.85)
281        )
282        self.set_camera_move_speed(max(1.0, radius * 0.35))
283
284
285def build_parser() -> argparse.ArgumentParser:
286    parser = argparse.ArgumentParser(description="View Python-generated terrain.")
287    parser.add_argument(
288        "--type",
289        default="mixed",
290        choices=("mixed", *TERRAIN_TYPES),
291    )
292    parser.add_argument("--rows", type=int, default=3)
293    parser.add_argument("--cols", type=int, default=3)
294    parser.add_argument("--tile-width", type=int, default=96)
295    parser.add_argument("--tile-length", type=int, default=96)
296    parser.add_argument("--horizontal-scale", type=float, default=0.05)
297    parser.add_argument("--vertical-scale", type=float, default=0.005)
298    parser.add_argument("--backend", choices=("cpp", "python"), default="cpp")
299    parser.add_argument("--seed", type=int, default=7)
300    parser.add_argument(
301        "--no-collision-test",
302        action="store_true",
303        help="Disable both PhysX heightfield collision and falling test bodies.",
304    )
305    parser.add_argument("--test-bodies-per-type", type=int, default=10)
306    parser.add_argument("--window-width", type=int, default=1600)
307    parser.add_argument("--window-height", type=int, default=1000)
308    return parser
309
310
311def main():
312    args = build_parser().parse_args()
313    app = ProceduralTerrainViewer(
314        terrain_type=args.type,
315        rows=args.rows,
316        cols=args.cols,
317        tile_width=args.tile_width,
318        tile_length=args.tile_length,
319        horizontal_scale=args.horizontal_scale,
320        vertical_scale=args.vertical_scale,
321        backend=args.backend,
322        seed=args.seed,
323        collision_test=not args.no_collision_test,
324        test_bodies_per_type=args.test_bodies_per_type,
325    )
326    app.initialize(args.window_width, args.window_height, False, ke.UpAxis.Y)
327    app.start()
328
329
330if __name__ == "__main__":
331    main()

procedural_terrain

Generate the height field

TerrainGrid joins adjacent tiles with a shared edge, producing one continuous height field rather than separate mesh islands.

import numpy as np
import kangengine as ke


rng = np.random.default_rng(7)
terrain_types = ("stairs", "slope", "wave", "random", "obstacles")

grid = ke.terrain.TerrainGrid(
    rows=3,
    cols=3,
    tile_width=96,
    tile_length=96,
    horizontal_scale=0.05,
    vertical_scale=0.005,
)


def generate_tile(tile, row, col):
    kind = terrain_types[(row * grid.cols + col) % len(terrain_types)]
    if kind == "stairs":
        ke.terrain.stairs_terrain(tile, step_width=0.35, step_height=0.08)
    elif kind == "slope":
        ke.terrain.sloped_terrain(tile, slope=0.18)
    elif kind == "wave":
        ke.terrain.wave_terrain(tile, num_waves=3.0, amplitude=0.35)
    elif kind == "random":
        ke.terrain.random_uniform_terrain(
            tile, -0.05, 0.05, step=0.01, rng=rng
        )
    else:
        ke.terrain.discrete_obstacles_terrain(
            tile,
            max_height=0.25,
            min_size=0.2,
            max_size=0.7,
            num_rects=80,
            platform_size=1.0,
            rng=rng,
        )
    return tile


grid.fill(generate_tile)

horizontal_scale is the distance between samples. vertical_scale converts the integer height_field_raw values into meters.

Render the terrain

Convert the grid to MeshData and add it through the normal scene API:

mesh = grid.to_mesh(up_axis=ke.UpAxis.Y, backend="cpp")
material = self.create_standard_materials().common

terrain_view = self.scene.add_mesh(
    "/procedural_terrain",
    mesh,
    material,
)

The C++ mesh backend is the normal path. Use backend="python" when prototyping or comparing terrain conversion behavior.

Add PhysX collision

Create collision from the same meter-valued height array:

physics = ke.physics.PhysicsWorld(ke.physics.PhysicsConfig.y_up())
heights = np.ascontiguousarray(grid.height_meters(), dtype=np.float32)

collision_added = physics.add_heightfield(
    heights.reshape(-1),
    grid.width,
    grid.length,
    horizontal_scale=grid.horizontal_scale,
    up_axis=ke.UpAxis.Y,
    center=True,
    register_as_ground=True,
    material=ke.physics.PhysicsMaterialDesc([1.0, 1.0, 0.0]),
)

Keep these values identical for rendering and collision:

  • height samples from grid.height_meters();

  • horizontal_scale;

  • up_axis;

  • the center setting.

Changing one side independently makes the collision surface appear shifted, rotated, or scaled relative to the rendered mesh.

Step the PhysicsWorld each frame and copy dynamic actor poses to their RenderablePrimView objects. The complete example implements creation, reset, and synchronization for both spheres and boxes.

Terrain generators

The public generators modify a SubTerrain in place and can be composed:

  • random_uniform_terrain()

  • sloped_terrain()

  • pyramid_sloped_terrain()

  • stairs_terrain()

  • wave_terrain()

  • discrete_obstacles_terrain()

Use a single SubTerrain for one patch or a TerrainGrid for tiled training and visualization environments.