One generator instead of ten review rounds: a procedural lookout tower in Blender Geometry Nodes
Adriano Muricy · Technical Artist
Made for Eggplant VFX (Toronto), Urban Legends, episode "Scuba Diver"

The problem
The episode needed a few versions of an old wooden fire-lookout tower. What the director hadn't settled was the design itself: how tall, how many sections, how thick the beams, how big the cabin, how many windows. Those choices depended on how each camera angle read, and the camera angles were still changing.
The usual approach is to model one tower, send it, get notes ("two sections shorter", "chunkier legs on the low angle"), rebuild it and repeat. Every round costs modelling time. Worse, the notes can conflict from shot to shot.
So I didn't send a tower. I sent a tower generator: two Geometry Nodes modifiers with exposed parameters, so the team could dial in a structure per shot and lock it when the framing was approved.
A note on the materials. They are deliberately simple. Most of these towers were never meant to be final pixels. They gave a correct 3D perspective reference that a DMP artist would paint over, or that a texture/sculpt artist would push further once the hero design was picked. The shading only needed to read as weathered wood and concrete, and it needed to behave when the geometry changed. More on that below.

The same two node trees with different modifier values. EEVEE re-renders from the generator file.
Scene layout
The asset is split across two generator objects plus a few helper objects:
| Object | Modifier stack | Role |
|---|---|---|
| Tower Base Gen | GN - Beam Gen 2 → Lattice |
Legs, cross-bracing, horizontal rings, concrete feet, stacking |
| Cabin Gen | GN - Top Cabin → Auto Smooth |
Floor, joists, railing, cabin walls, glass, shingled roof, ladder hatch |
| Lattice (2×2×2) | used by the tower and the ladder | Flares the base: the bottom ring of points is scaled ×1.47 |
| Empty + 3 ladder meshes | ladder rails and rungs (Array) | The Empty also drives the hatch cut in the cabin floor |
| bracket 1 / bracket 1.001 | hidden | Source meshes for the metal brackets, pulled in with Object Info |
Exposed controls on Tower Base Gen: GRID SIZE, GRID HEIGHT, Amount (sections, 1–8), Inner Beam Section (cm), Horizontal Beam Thick (cm), Horizontal Beam Height (cm), THICC UPBEAM Thickness, Cement Base Width / Height / Top Size, Upright Beam Height.
Exposed controls on Cabin Gen: Floor Size, Floor Plank Width (cm), Floor Plank Depth (cm), MidBeam Thickness (cm), Roof Drop (Desceção do Teto), Cabin Roof Height (Altura Teto Cabine), Inner Cabin Scale, and Value (railing density).
Every "(cm)" input is multiplied by 0.01 inside the tree. The people using the tool could think in lumber dimensions while the scene stayed in metres.
Part 1: Tower Base Gen

The tree has 189 nodes organized in labeled frames. Almost everything is built from one idea, repeated:
Make a tiny 2×2 grid, turn its edges into a curve, and sweep another curve with it.
A 2×2 Mesh Grid has four vertices and four edges: a square. Run it through Mesh to Curve and Set Spline Cyclic and you get a closed rectangular profile. Feed that profile into Curve to Mesh (Fill Caps on) with any path, and you get a capped timber beam of exactly the section you asked for. Beam dimensions are just the grid's Size X / Size Y.
MAIN GRID: where the uprights go
Two 2×2 grids define all the placement:
- A grid of
GRID SIZEgives 4 corner points for the thick outer legs. - A second grid of size
√(0.5 · GRID SIZE²)(thePower → Multiply 0.5 → Sqrtchain), rotated 45°, puts its 4 vertices on the midpoints of the outer square's edges. Those are the inner uprights on each face.
No hard-coded positions. When the footprint changes, both point sets follow.
UPRIGHT BEAMS: lines on points, then lathe
- Instance Lines On Points: a Curve Line from 0 to
(0, 0, GRID HEIGHT), resampled to 2 points, is instanced on every placement point. That gives one vertical segment per point for one section. The Spline Parameter → Factor is captured here so the beam has a V coordinate later. - INNER BEAMS (lathe lines into beams): the segments are swept with a profile grid of
Inner Beam Section × 0.01. The captured factors become the output attributeup_beam_uv. - OUTER THICC UPBEAMS: the same sweep on the corner points with the
THICC UPBEAM Thicknessprofile.
X FRAME: cross-bracing from four vertices
This is my favourite trick in the file. The X bracing on every face comes from a single 2×2 grid:
- Grid of
GRID SIZE, scaled to 0.99 so the braces sit just inside the legs. Index % 1.5splits the four vertices into two diagonal pairs. A Boolean NOT gives the other pair.- Two Set Position nodes raise each pair by
GRID HEIGHT(the offset comes from the SECTION HEIGHT frame). - Both results are joined. In each copy, every edge of the square now runs from a low vertex to a high vertex, so every edge is a diagonal. The two copies lean opposite ways and cross into an X on every face.
- Mesh to Curve → sweep with the inner-beam profile. The Factor is captured into
beams_vec_2.
That's four vertices and a modulo in place of a bracing system that would otherwise need its own math per face.
HORIZONTAL BEAMS: rings, IDs and brackets
- Rectangular rings of
Horizontal Beam Thick × Horizontal Beam Height: one at the base of the section and a rotated duplicate atGRID HEIGHT × 0.5. Two rings per section. - Scale Elements 0.997 on the edges pulls the ring ends in slightly so they don't z-fight with the legs.
- The edge Index is stored as
beam_id. It is later combined with a Random Value (±0.3, seeded by that ID) and exported, so the shader can offset each beam's texture. - Metal brackets: Object Info pulls in the hidden
bracket 1/bracket 1.001meshes. They are instanced at the corners at 0.12 scale, rotatedIndex × 90°so each faces outward, and given theRusty painted metalmaterial.
BASE ELEMENTS: feet that know where the stack starts
- Concrete footings: grid (
Cement Base Width) → Extrude Mesh individual (Cement Base Height) → scale the top faces (Cement Base Top Size) for a tapered pier → Subdivide ×4 → Set Position with a Noise Texture offset (scale −0.03) so they aren't perfect CG boxes →Procedural rough Concrete. - Wooden stubs extruded to
Upright Beam Heightsit on top of the piers. - An Attribute Statistic reads the max Z of the footings and passes it as the Translation of the stack. Change the pier height and the whole tower sits on it correctly, with no manual offset.
MANUAL DUPLICATES: stacking sections
A section is X frame + uprights + horizontal rings, joined. The Manual Duplicates group then builds the tower height:
- Frames
COPY 1toCOPY 7, each with a Math: Greater Than (Amount > n) → Switch → Transform withZ = n × GRID HEIGHT. - Everything is joined and moved up by the footing height.
The result is 1 to 8 stacked sections from one integer. It's the most brute-force part of the file, and I come back to it in the last section.
Lattice: the flare
The raw generator output is a straight prism. A 2×2×2 Lattice modifier with the bottom ring of points pushed out (×1.47) gives the splayed-leg silhouette of a real fire lookout. The ladder meshes use the same lattice so they follow the lean.
Part 2: Cabin Gen

The cabin tree is bigger (244 nodes). Nearly every dimension is derived from Floor Size, so resizing the platform carries the railing, walls and roof along with it.
FLOOR BASE
- PLANK BASE: a Mesh Line with
Count = Floor Size / plank widthplaces one point per plank. A plank sweep (width and depth in cm) is instanced on each point. The instance Index is captured before realizing and written intoplank_uvw.z. Spline Parameter givesplank_uvw.xy. - MID BEAMS GRID: joists under the floor, sized by
MidBeam Thickness (cm), duplicated and rotated to run both ways. - Outer Ring and PEITORIL TOP: a grid at
Floor Sizewith inner faces deleted and extruded into the rim, plus the handrail cap duplicated from it.
LADDER ENTRY CUT
Object Info reads the ladder's Empty, positions a cube at it, and runs a Mesh Boolean (Difference) against the realized floor. Move the ladder and the hatch in the floor moves with it. One of the few places where two objects actually talk to each other.
PEITORIL (railing)
Grid → Mesh to Curve → Resample Curve with Count = Value × 4 → cube balusters instanced on the points, with a random value per instance (rnd_1).
CABIN
The walls come from a grid at Floor Size − Inner Cabin Scale → edges → resampled → vertical plank sweeps. Each plank gets a random value in cabin_planks.z. The glazing is a Cube with 5×5 vertices, extruded and inset (scale 0.9), with the inset faces assigned gLASS.
ROOF › Top Pyramid
- Grid → Extrude (
Roof Drop) and scale the top → hip roof shape. - Distribute Points on Faces (Poisson, min distance 0.15) → cube shingles with random scale 0.8–1.6, rotation aligned to the face normal (Align Euler to Vector), and a small random Z jitter so the tiles overlap unevenly.
- Each shingle's point position is captured into
tile_posbefore instancing.
Part 3: Attributes as the bridge to the shaders
Procedural geometry has an obvious texturing problem: there are no hand-made UVs. The mesh changes every time someone moves a slider. If the wood relies on object or generated coordinates, textures swim, stretch and repeat identically on every beam.
The fix is to make the node tree author the texture coordinates:
- Every profile grid stores its own UV Map into
uv_map(Face Corner). - Along each sweep, Spline Parameter → Factor is captured and combined with the profile coordinate. That gives every beam its own 0–1 space.
- Per-element values (plank index, per-plank random, per-beam ID) are captured before Realize Instances, while the instance domain still knows which piece is which.
- These values leave the modifier as Output Attributes and are read in the materials with Attribute nodes.


Left: final shading. Middle: the stored uv_map on a checker. Right: the custom output attributes the materials actually read. Debug passes use a temporary material override.
The floor shows the idea best. plank_uvw.z is the plank index. The shader adds it as an offset before the Mapping node, so every plank samples a different part of the same wood texture:

The wood materials share one structure (frames: Textures, Mapping, MAIN COLOR GEN, BLACK SPOTS, saturation randomizer, EXTRA BUMP). Only the coordinate input changes per part. That's why there are several near-identical "Raw Wood" materials: _ Upbeam, - Xframe, - Outer Beams, _floor_plank, _cabin planks. Each one listens to a different attribute. The shingles (Wood Rough 001a) use tile_pos, so every tile gets its own patch of the texture without any per-tile UV work.
For a paint-over reference that's exactly enough. The wood reads as wood from any camera and keeps reading as wood when the tower is rebuilt with different numbers.
Numbers
| Tower Base Gen | Cabin Gen | |
|---|---|---|
| Nodes (main tree) | 189 + 51 (Manual Duplicates) | 244 |
| Evaluated vertices | ~187k | ~40k |
| Evaluated faces | ~75k | ~36k |
| Material slots | 5 | 10 |
At the file's default values, both generators together land around 110k faces. That's comfortable for a layout or reference asset to push around in the viewport across multiple camera setups.
What I'd change today
The generator did its job: the team could iterate without me in the loop. Looking at it again with fresh eyes, these are the honest weak spots:
- Stacking is hand-wired.
Manual Duplicatesis seven copy-pasted Switch + Transform frames with a hard cap of 8 sections. A Mesh Line withCount = Amount+ Instance on Points (or a Repeat Zone) does the same in three nodes, with no cap. - The cabin doesn't know where the tower ends.
Cabin Gen,Vertical Base, the ladder Empty and the Lattice are separate objects placed by hand. ChangeAmountorGRID HEIGHTand they have to be moved. The tower's top height should drive them, either by merging the cabin into the same tree or by passing the top Z through Object Info. - UVs use Spline Parameter Factor, not Length. Factor is normalized 0–1 per curve, so a taller section stretches the wood grain and the shader compensates with a fixed multiplier. Length would give constant texel density whatever the dimensions.
- Some "variables" are still constants. The glass cube's vertex count (5×5) and the "Nro de Taubas" resample (7) are set inside nodes, not on the modifier.
- Dead outputs.
beams_vecandrnd_1are exported but no material reads them. - Interface hygiene. No input panels, and the naming mixes Portuguese and English (plus typos like "Floor SIze" and "Cabin Plnaks"). Fine for me; less fine for a vendor opening the file cold.
None of these blocked production. They're the difference between a tool built under a deadline for one show and a tool you'd hand to someone else for the next one.
Software: Blender (Geometry Nodes, Cycles / EEVEE). File revisited in Blender 5.2.