Birdoggydog's Builds

Placing generated rocks in Godot and stepping up onto them

26 basalt and granite pieces placed by a hashed lattice from a manifest each ships with, a line-ending fault that staled every record, and a 0.5 m step-up rule.

The 26 rock pieces from the rock generator now stand in Blobber, placed from a small manifest each one ships with. The party also walks up anything half a metre tall or less, so the low rock slab is walked onto without a jump. Both landed on 10 October. The work is done by AI coding agents that I direct.

Placing rocks from the manifest each piece ships with

There are 24 basalt pieces and 2 granite: boulders, clinker, an outcrop, a ledge, columns, spires, a slab, a rim block, a tor and six arches. A boulder stops the party, clinker is walked through, an arch is walked under and the slab is stood on. It is still a trial and off unless asked for. One key shows the rocks alone on the island, and another adds the old stand-in trees and huts.

Rocks at a cove, a boulder that stops the party, clinker underfoot, an outcrop, a walk through an arch, the slab, a granite tor on a snowy ridge, and a long view with the levels of detail in colour.

Until now the game’s only rock was a placeholder made in code, and the scatter trial drew grey stand-in shapes.

Beside each .glb and its three textures (base colour, normal, and one packed occlusion, roughness and metal) is <id>.piece.json. This is a large boulder’s, cut down:

{"id": "boulder_l", "class": "rock", "family": "plug_basalt", "seed": 7,
 "footprint_m": 1.05, "height_m": 1.75, "up": "ground", "sink_m": 0.0,
 "slope_max_deg": 90.0, "tip": true,
 "collision": [{"shape": "cylinder", "radius": 0.94, "height": 1.75, "at": [-0.004, 0.875, 0.001]}],
 "lods": [{"node": "SM_rock_boulder_l_LOD0", "triangles": 300, "shadow": true},
          {"node": "SM_rock_boulder_l_LOD1", "triangles": 70, "shadow": true}],
 "sockets": [{"kind": "top", "at": [-0.004, 1.749, 0.001], "dir": [-0.053, 0.998, -0.042]}]}

The game reads class (which lattice, which distances, which budget row), lods (a mesh a level, by node name), collision, up, sink_m, slope_max_deg, tip, the footprint and height, and biomes. It doesn’t yet read the sockets, which are where meat growth attaches.

There are three classes. rock is the 8 boulders, with two levels of detail: the first to 56 m and the second to 260 m. landform is 15 pieces with three levels, to 110, 280 and 1,500 m. clutter is the 3 clinkers, 27 to 29 triangles each, with one level to 56 m and no collision. A boulder is 296 to 300 triangles, then 69 to 70. A landform is up to 1,500, then about 400, then 48 to 60.

None of the 26 was rebuilt for this. An earlier check had rebuilt all of them with the kit as it stands and got the same bytes. Each went in through the pipeline’s install command, which refuses a model whose record of the 19 pipeline steps is incomplete, failed or made for other bytes. None was refused.

How does the rule decide which rock stands where?

The rule is a jittered lattice hashed from the world position. A place always grows the same rocks, and a save stores nothing. Rocks share a 6 m lattice with the stand-in trees. Landforms sit on a 48 m lattice at a chance of 0.02 to 0.14 a site, by biome. Clinker takes 0.3 of the clutter’s chance.

A table in JSON gives each piece a weight, optional biomes and a steepest slope:

"pieces": {"outcrop": {"weight": 3}, "slab": {"weight": 2, "slope_max": 12},
           "granite_tor": {"weight": 4, "biomes": ["cold_upland", "fell", "alpine"]},
           "arch_eye": {"weight": 0}, "spire_split": {"weight": 0}}

For each biome the rule builds the list of variants allowed there and their summed weight, then picks by the site’s hash. Only three different boulders are used within one 192 m place and two different landforms within a 384 m place, so neighbours repeat and can be drawn together. Scale is 0.6 to 1.3 for rocks.

Each variant brings its own radius. Two pieces that block the party are never closer than 2.4 m edge to edge. That is a minimum, not a spacing. The nearest pair measured was 2.44 m.

Weight 0 means “never by the dice”. I’m pretty happy with the boulders. But when I saw arches placed by dice I wanted them managed much more carefully, as somewhat unique features in the world. So the six arches got weight 0 and are stood by hand, like the split spire. scatter.place(id, at, yaw, scale, pose) stands a piece by hand, and the rule keeps the same gap from it.

Drawing a rock, and stopping the party on it

Drawing is Godot MultiMesh instances: one for each piece, level and cell of the streamed world. Only first levels get a shadow pass. One shader serves all stone.

A grassy hillside above a bay, dotted with rocks tinted bright green near the camera, yellow further off and orange in the far distance.
From 340 m: green is the first level of detail, yellow the second, orange the far stand-in.

The budget document assumed one material for a whole stone family. That was a dead end. Every piece is baked to its own three textures on its own UVs, so it is one shader and a material for each piece and level. A piece’s meshes and textures are loaded on the worker thread that works a cell out, the first time a cell holds it. Opening it on the main thread then takes 0.1 to 0.8 ms.

The manifest’s boxes and cylinders become static shapes, turned and leaned with the piece. A piece marked walk_over gets none. An arch has three boxes: two legs and the span, with the span high enough that the way under it is clear.

Looking up through a tall arch of dark pitted basalt blocks on a grassy plain, with hills and blue sky seen between its legs.
Under the eye arch. Its legs are solid and the way under it is clear.

A piece marked tip may stand in one of five poses: as built, or rolled onto another face. The manifest gives solid shapes for the built pose only, so the game turns the bounds of the built shapes with the piece. A tipped pose that would break the 2.4 m gap is dropped, which is what happens to the tall boulder on its side.

Measured in the game at 1280 by 720, the rocks alone add 0.14 to 0.17 ms a frame and 117 to 169 draw calls over nothing at all. That is about a third of the 0.5 ms the scatter is allowed. All 26 open take about 35 MB of texture memory on the video card. The count is over budget, though: 382 rocks stand within 260 m at one site and 673 on a ridge, against a budget row of 200. I left that as it is for now.

The clinker looks good for now, but it’s hard to tell without grass and trees.

Why did every rock’s record go stale after the merge?

Every model has a record beside it listing the pipeline’s 19 steps, tied to the model’s bytes by hash. A placed piece’s record also holds the hash of its .piece.json. After the merge all 26 rocks failed the pipeline test as stale. The same test had passed on the branch.

The kit writes the manifest through Python’s text mode, which on Windows writes CRLF. The repository’s attributes say * text=auto eol=lf, so the commit stored LF and the main checkout got LF. Different bytes, stale record. The branch’s own copies stayed CRLF on disk, which is why the test passed there.

The mend was one attribute line, *.piece.json -text, and the 26 built files put back. The proper fix is to write LF, which changes the bytes, and then record every piece again. That waits for the next rock build. The lesson for landing: when a branch installs models, the pipeline test is run on the merged result as well as in the branch.

Walking the party up half a metre

40 seconds in first person. A row of signed risers from 0.10 to 0.60 m walked at three step heights (0.25, 0.35 and the chosen 0.5 m) until one stops the party; the rock slab walked onto and off; a large monster following up steps until a 0.60 m riser stops it; a dungeon door that still stops the party when shut.

The party now walks up a rise of 0.5 m or less and is stopped by anything taller. Before this it climbed only what the round foot of its capsule rolled over, about 12 cm. The slab is one box 0.23 m tall, so it could only be reached by jumping.

One new script, StepRule, has a single entry point, StepRule.slide(body, height). It replaces the bare move_and_slide() call in the party, in monsters and in raised allies. All of them are Godot CharacterBody3D capsules. Changing the capsule’s shape or adding separation rays were both turned down, because both change numbers that already have tests.

Before the slide, the rule tries the move. If the body would be stopped, it casts a ray 5 cm past the body’s edge to find the top of what is in the way. Then it tests three moves: up by the rise, forward, and down. If all three are free and the rise is no more than the step height, the body is lifted 2 cm over the top and the normal slide carries it on.

  • The rise is measured over the ground the body stands on, so a slope doesn’t use up the allowance.
  • No step is taken in the air or while rising, so a jump reaches exactly what it did.
  • Never stepped onto: another character body, anything interactable (doors, chests, people), a corpse, a themed prop, and anything in the group no_step.
  • A monster’s step is a fifth of its size class’s typical height, 0.24 m for the smallest class to 0.58 m for the largest. Flyers are left alone.
  • The camera is held back by the step’s rise and eased toward the body, at a rate proportional to what is left and never slower than 0.35 m/s.

The first build proposed 0.35 m, a fifth of the 1.8 m body. I looked at a row of risers from 0.10 to 0.60 m walked at 0.25, 0.35 and 0.5 m, and went for 0.5 so that a range of steps is possible. With the rule on, kerbs of 0.15, 0.20, 0.30 and 0.50 m are all climbed. Walk speed, run speed and the climb up a 40 degree ramp measure the same with the rule on and off.

The height sets rules for generated pieces. A riser meant for the party is at most 0.45 m. One every monster should take is 0.20 m. A wall is at least 0.65 m, so that it stops the largest monster. Stairs are boxes with risers of 0.15 to 0.20 m.

There is no code for stepping down. The capsule rolls off an edge and drops for up to 0.19 seconds, and that seems fine for now. At 0.5 m the party out-steps most monsters. Any plain solid object up to 0.5 m tall with a level top is now walked onto, and nobody has walked the authored places looking for one that was meant as a barrier.