Laying a dungeon floor from generated pieces in Godot
The first floor of the Breakout Site now uses my own walls, floors, ceilings, lights and doors, placed by a rule, with a tiling detail layer for close-ups.
The first floor of the Breakout Site is built from my own generated interior pieces now. The floors, ceilings, walls, ceiling lights and 12 of the doors are the muted cyberpunk set I made in Blender, and the old rust faces the importer made in code are gone from the squares I laid. It’s only part of the floor, though. Caverns, narrow passages, crawl tunnels, the tall hall and floors 2 to 4 still have the old faces, so laid squares stand next to rust walls for now.
Why lay the whole dungeon from my own pieces?
I wanted the dungeon built from pieces that fit the game’s style and not from the assets already in it. When I saw the muted palette I said “Muted! Amazing”. The worn pieces were another matter. I said the stain was too uniform and needed a bit more noise.
How does the importer know what to lay?
A dungeon asks for it with one field in its content file, "interior": "cyberpunk". I didn’t call it theme, because theme already dresses a dungeon’s packs and is copied to every floor.
The rule has four parts. Each laid square gets a floor and a ceiling. Every side of an open square with rock beyond gets a wall, 1.5 m from the square’s middle with its front toward the square. A door’s square gets a frame and two leaves. A lamp’s square gets a light under the ceiling. Walls cycle through three variants by column and row, and floors through two. There is no corner piece, because every run is mitred inside the piece.
A piece is made for a plain square, 3 m wide under the 4 m ceiling. So a square is laid only when it’s open from edge to edge, under that ceiling, not a cavern and not a lift shaft. Each laid square is new or worn. That’s seeded by a hash of the dungeon’s id and the square, so it’s the same on every visit. A room’s share of worn squares is one number for the whole room. It’s 0.33 where there’s no cavern. Next to one it’s 0.85, falling to 0.2 at 11 squares away. On this floor the cavern is the meat vat chamber, so the place gets worse the closer you get to the vat.
Cutting the old faces out
The importer hands over floor and wall faces already merged across squares. A new mesher, InteriorMesher, cuts each floor or 4 m ceiling face at the square lines and drops the squares that are laid. It does the same for wall faces on the sides where a wall piece stands, and draws everything else as before. Collision is still the importer’s boxes, so the map, rays and every monster’s path are the same as before. A test checks that solid shapes, rays, the map’s survey and the rock boxes match before and after.
For draw calls, every piece of one kind and state goes into one MultiMesh. The alternative is one node per piece. With batching it’s 1,188 nodes against 3,065.
Why did the whole floor turn cyan?
My first look at it was all cyan. The light piece’s color is the glow of its fitting, and when I used that as the color of the actual light, it turned the whole floor cyan. The lights now take the sheet’s lamp color, energy and reach, and the piece keeps only the fitting’s glow. A lamp becomes the light piece only over a laid square with nothing hung or fitted there, and the piece goes in the middle of the square. A room’s lamps aren’t on square middles, so some move by up to 1.5 m.
Giving the walls something to look at up close
A piece’s texture is about 100 texels per meter, which is soft when you stand next to it. Under it I put the theme’s tiling set, through the detail layer of the material:
material.detail_enabled = true
material.detail_blend_mode = BaseMaterial3D.BLEND_MODE_MUL
material.detail_uv_layer = BaseMaterial3D.DETAIL_UV_2
material.uv2_triplanar = true
material.uv2_world_triplanar = true
material.uv2_offset = -(origin * scale).posmod(1.0)
The tile multiplies the piece’s picture. Triplanar projection in world space means the tile carries on across pieces instead of restarting on each one, and the offset keeps it from jumping at a dungeon’s origin. Door leaves move, so their tile is projected in the leaf’s space.

The stain was the other fix. The bake’s color and over-layer are multiplied by a noise field at two sizes, so the worn state has clean stretches and whole patches, and grime runs under the seams between levels.
Doors
InteriorDoor extends the game’s existing door. It builds the frame and two leaves, and the leaves slide into the rock along the axis and distance in the leaf’s manifest as the game clock advances. The status bar goes on the lintel and the lock panel on the first leaf. The doors in the subject cell are in narrow squares, so those stay blast doors.

After the merge, the test that holds the save format failed. The new door class has five variables the save schema didn’t name as saved or not saved. They describe how the door was built, so I named them as not saved. Whether the door is open or shut still belongs to the parent door class.
What’s still wrong
The door’s solid box is the blast door’s, 3 m wide, but the frame’s opening is 2.5 m, so an open door lets the party brush through 0.25 m of jamb on each side. I haven’t fixed that. The ceiling of a laid room is nearly black, worn floors show missing plates as black holes, a worn light is a dim teal, and one of the three walls is nearly all stained. I haven’t fixed any of those either.
Numbers
A test with 37 checks passes. On floor 1 it counts 603 squares laid, 186 of them worn, 603 floors, 603 ceilings, 606 walls, 79 light pieces for 101 lamps and 12 framed doors. The mesh the importer used for the Look has 852 triangles now where it had 2,332.
I measured six views, windowed with vsync off. Before, the median frame was between 9.3 and 14.0 ms. Laid and batched, it’s between 8.1 and 12.4 ms, so no view got slower. Draw calls went up between 0 and 18 percent, for example from 5,584 to 6,610 in the first view. I measured that while the lights were still cyan, and I don’t know why the floor already costs about 5,000 draws. Building the floor took 94 ms before and 480 to 660 ms laid, which includes loading the 20 piece files for the first time.