
33 min read
Carnegie Hall, rebuilt from a seating chart
How I turned a flat ticketing map into a complete Stern Auditorium: 2,758 seats, four tiers of gilded parapets, vaulted ceilings and hundreds of real light fixtures. I did it by building tools instead of sculpting.
On this page
Technical art breakdown · 27 July – 16 September 2026 · Blender 5.2 · Houdini 20.5 · Cycles
I'm moving my career toward technical art. The part of this work I enjoy most is not the final pixel but the system that produces it. So I set myself a deliberately awkward brief: recreate the Isaac Stern Auditorium at Carnegie Hall as a real-time-ready environment, and start from nothing but a seating chart and reference photos. No scans, no architecture asset packs, no sculpting.
That constraint shaped everything. A seating chart is a precise 2D record of the hall, since every seat, box and aisle is in the right place, and it contains no height, no ornament and no light at all. The job was to lift that data into three dimensions. Then I dressed it with a vocabulary of classical ornament generated from code, and lit it only with lamps that exist in the model. This post walks through that pipeline in the order I built it, including the parts that failed.
Prelude
How I worked
Almost nothing in this hall was pushed into shape by hand. Every asset comes from a script: Houdini builders run headless through hython, and Python drives Blender live through the Blender MCP bridge. I used Claude Code as a scripting copilot that wrote and ran that code against my scenes. My job was the part a technical artist owns: breaking the building into systems, choosing the technique for each, reading the reference, setting the rules, and judging every result against the photos until it was right.
The feedback loop mattered more than any single tool. I drew directly on viewport screenshots, with blue lines for paths and red circles for problems, and those drawings became the input to the next build.
- ReferencePhoto of the real detail, cropped to one element.
- AnnotateBlue path or red circle drawn on a viewport screenshot.
- BuildA parametric script generates the geometry.
- MeasureRatios, areas, volumes and open edges checked as numbers.
- ReviewRender, compare with the photo, give one precise note.
Stage 1 · 27–30 July
From seating chart to vectors
The official Stern Auditorium seating chart is a vector PDF, which makes it a survey drawing that happens to sell tickets. I converted its two pages to SVG. Page one holds the parquet and the first and second tiers. Page two holds the dress circle and the balcony. From there I pulled out two kinds of data.
Architecture: strip everything that isn’t a wall
The full page-one SVG carries 4,913 paths and 5,948 text elements, because every seat is a numbered box. I produced a second file that keeps only the architectural strokes: the stage house, the parquet walls, the curve of each tier and the radial lines that divide the boxes. That outline file, 1,514 paths and no text, is the floor plan everything else was traced against.
Seats: every symbol becomes a point
Each seat on the chart is a small closed shape. I took the centroid of every one and wrote it to a CSV with its page, a cluster id and x/y in points. Clustering by proximity grouped the seats into their real sections without reading a single label. On page one, the three parquet blocks came out as clusters of 310, 311 and 385 seats, and each tier box came out as its own group of about eight. That file is the seat layout. Scaled to metres, those points drove seat placement in the first Blender scene, which held 2,801 seat instances.
Blockout layers
For the tiers I redrew the plan as clean, named layers in a vector editor: OUTLINE, BASE, FLOOR and BOXES, with each of the 33 box partitions as its own shape. A second map colour-codes the dress circle rows by terrace step. Those colours are the data that later told each row which step height it sits on. Imported into Blender and extruded, these layers became the first low-poly blockout: floor slabs, terraces and partitions made from primitives.
By 3 August the blockout could be lit and rendered. It’s crude, with a placeholder stage wall and flat red seats, but every seat and tier is already in its surveyed place. From here on, the work was about replacing primitives with the real thing, one system at a time.
Stage 2 · 30 July – 3 August, revised 1 September
A procedural hero-asset kit in Houdini
The hall repeats a small set of architectural pieces many times: tier columns, consoles, arches, doors and ceiling panels. Each one got its own Houdini builder: a Python script that constructs the whole SOP network from scratch, renders previews in Karma and exports OBJ, FBX and GLB. They share one helper library, so every asset uses the same materials, conventions and export fixes. Nothing is baked, and every asset rebuilds with one command.
"C:\Program Files\Side Effects Software\Houdini 20.5.370\bin\hython.exe" build_arch.py --export --finalThe same few ideas carry the whole kit:
- A silhouette is one profile curve. A wrangle turns the profile’s X into
pscale, a chamfered cross-section is copied to those points, andskinlofts the body. A primitive wrangle maps each face ring back to its profile segment, which assigns gold or ivory, so retiming a moulding keeps its gilding. - An ornament is a template plus copy-to-points. Beads, reeds, dentils and egg-and-dart are single small meshes laid along a
resampled curve, so a longer arc simply gets more beads at the true size. - Proportions are measured, not eyeballed. Each builder reads its ratios off the reference photo and prints a reference-versus-model table on every run.
The tier column
The first asset was the gilded pilaster that stands between the boxes. The final version has 17,108 polygons in two PBR materials. It also taught the first hard lesson. My early acanthus leaves were assembled from flat plates, and at render distance they read as fir trees or scattered debris. Light breaks at every seam between separate pieces, so no arrangement of them looks carved.
The GLB export needed its own fix, and I folded it into the shared library so every later asset got it for free. Houdini’s glTF ROP leaves doubleSided unset, so single-sided leaf ornaments vanished under backface culling. It also drops emission, so lamps came out as dead grey plastic. A small post-process patches the binary container directly:
def patch_glb(path, mesh_name=None, emissive=None):
...
js = json.loads(chunks[0][1].decode("utf-8"))
for m in js.get("materials", []):
m["doubleSided"] = True
pbr = m.setdefault("pbrMetallicRoughness", {})
pbr.setdefault("metallicFactor", 1.0)
# the exporter drops emission, so lamps come back as dead grey plastic
if emissive and m.get("name") in emissive:
m["emissiveFactor"] = emissive[m["name"]]
m["extensions"] = dict(m.get("extensions", {}),
KHR_materials_emissive_strength={...})The arch: one knob instead of many
The ornamental arch is one moulding profile, in radius and depth, swept around the arch path. That single loft produces the bead astragal, the rinceau band, the coffered splay and the soffit reveal. The art direction came as short notes: make the opening bigger, stop the frame from extending past the arc, make it 10% wider. Each note became one parameter, and the rest of the build derives from it.
R_SHIFT = 1.350 # pushes the band outward; frame, jambs, ornament counts, lights and cameras follow
FRAME_W = 0.420 # moulded border width along the top
HX = R_OUT # half-width IS the arch radius: no dead panel beside the jambs
WIDEN = 1.10 # final X-only stretch, pivoted on the arch axisThe ceiling rose
The ceiling panel is a radial problem, so it’s one radial profile swept around a 72-segment circle, with the flat surround lofted from the circle out to the rectangle. I measured the reference plate against the panel’s half-width and drove every ring from those fractions:
| Feature (fraction of half-width) | Reference | Model |
|---|---|---|
| Outer bulb ring | 0.940 | 0.940 |
| Beaded ring, outer | 0.925 | 0.925 |
| Foliate annulus, outer | 0.893 | 0.893 |
| Foliate annulus, inner | 0.675 | 0.675 |
| Dense light ring | 0.599 | 0.599 |
| Dome springing | 0.563 | 0.563 |
Matching ratios wasn’t enough. The first version was square with thin ornament, while the real panel is a rectangle with side panels and lamps in the inner cove. The rebuild measured the bay proportions: the centre coffer is 56.1% of the width and each side panel 19.8%. It also replaced a spiky leaf fan with a continuous rinceau vine and added 96 upright lamps in the cove, exported with real emissive strength.
Doors: exit and box
The exit door architrave uses a neat trick: an offset of a rectangle is just a bigger rectangle, so lofting one offset outline per profile step gives exact mitred corners with no sweep and no boolean. The EXIT sign is a font SOP extruded into a housing with an emissive red material. When I later asked for the sign to sit inside a taller frame, the height was split into DOOR_OPEN_H plus TRANSOM_H. The architrave and its ornament simply ran taller, and the door leaves stayed bit-identical.
The box doors reuse the same library. The frame band measured 0.201 of the opening width, which is the same ratio as the exit door. Its bead-and-reel is one repeat copied along the frame path, and the three raised panels are placed from fractions of the leaf height read off the photo.
The fluted column, and choosing ornament that code does well
The balcony columns taught the most useful lesson of the project. The shaft flutes are not booleans. The shaft is lofted plain, and one wrangle pushes each point inward by a cosine of its angle, with an amplitude that fades in and out with height. The ring has 96 points, exactly four per flute, so groove bottoms and fillets both land on samples.
float th = atan2(@P.z, @P.x);
float r = length(set(@P.x, 0, @P.z));
r -= flute_amp * 0.5 * (1.0 + cos(24 * th)); // shaft: 24 flutes cut in
r += gadroon_amp * 0.5 * (1.0 + cos(16 * th)); // capital: 16 gadroons raised out
@P.x = r * cos(th);
@P.z = r * sin(th);The capital was the problem. Three attempts at hand-built acanthus went from flat plates, to exploding shards, to a lofted leaf surface that still read as crushed rock. So I changed the brief rather than the technique. I kept the classical vocabulary and chose an ornament that is a surface-of-revolution problem: gadrooning, a ring of convex lobes. It’s the same wrangle with the sign flipped. It reads as carved because it is one continuous surface, and it came out lighter at 11.2k polygons.
The console bracket
The pilaster with a scrolled console and a dentil cornice was the fussiest proportion problem. A moulding that turns a corner offsets its plan rather than scaling it, so the cornice plan is a function outline(u) that every profile sample rides, and the mitres fall out for free. The iterations were almost all about which way the cornice block runs: its width comes from the column, and its long axis goes into the depth.
The proscenium: fitting a shape, then re-bending ornament onto it
The great arch over the stage is where measurement paid off most. My first build assumed a circular segment. For a segmental arc the radius follows from span and rise:
R = (S²/4 + f²) / 2f # span S, rise f: rise/span 0.1255 gives R = 12.75 m at a 12 m spanIt still looked wrong at the ends. So I modelled the correct outline as a simple band in Blender and exported it as the ground truth. Walking its boundary loop and fitting curves showed why:
| My first build | Blockout band (truth) | |
|---|---|---|
| Curve | Circular arc | Semi-ellipse, a = 14.8, b = 3.2 |
| Rise / span | 0.154 | 0.108 |
| Tangent at springing | 34° | Vertical |
| Fit error | – | 0.0024 over 200 points |
A circle can’t land vertically, and that was the error. Instead of remodelling, I took the finished ornamental arch and swapped its semicircle for the ellipse in the four functions it hangs everything on. A “radius r” became an offset along the ellipse normal, and ornaments were spaced by true arc length. One uniform scale of 2.2335 made the two band widths agree, so the longer curve simply carries more ornament at the same size: 51 coffers, 63 rinceau motifs and 139 palmettes. The last pass made the section symmetric so both faces are moulded. That pass also exposed a classic gotcha: Houdini’s Mirror SOP outputs the original and the reflection. Merging it beside the original had tripled the ornament, 135,966 polygons instead of 94,804, and the arithmetic gave it away.
Stage 3 · 3 August & 1 September
Making inherited geometry trustworthy
A blockout extruded from a chart is not clean. The terraces were soups of disconnected quads, ornament strips were open shells, and several objects were fused together. This is the unglamorous core of technical art: you can’t build on geometry you can’t trust, and you can’t trust it until a number says so. Every fix below ended with a measurement, not a glance.
- Symmetry by cut and mirror. The dress circle was asymmetric. An Exact-solver boolean removed one half, and the cutter’s edge sat 1.7 mm past centre, so I snapped the seam vertices to exactly x = 0 before mirroring. Left alone, that becomes a 3.4 mm band of doubled faces down the middle.
- When the boolean lies. The balcony cut reported success but left 39 tiles inside the cutter. The cause was 964 duplicate coincident faces, which make the winding number ambiguous. The fix removed the duplicates, deleted every face inside the cutter with a ray-cast parity test, and bisected the straddlers.
- Retopology by constrained Delaunay. A broken patch with faces of up to 22 sides was rebuilt from its boundary loop, with 130 edge vertices pinned as constraints and a fresh triangular lattice inside. The minimum angle went from 1.13° to 2.67°, the median to 41°, and the area was unchanged to four decimals.
- Flat footprints of stepped floors. Flattening a terrace stacks each tread on the soffit above it. Solidifying, running an Exact self-union, bisecting and filling gave a clean plate. I checked it with 3,000 random point samples: none fell outside the original silhouette.
- Splitting fused objects without crashing Blender. The tier copings were one object made of 5,827 six-vertex fragments, and an edit-mode separate took Blender down. The rebuild split by Z bands with
from_pydataand no operators, which was lossless: 23,332 vertices in, 23,332 out. - Closing open shells. Swept ornament strips had no back faces, with 764 boundary edges. Because the back was a vertical plane, every top-back vertex paired exactly with a bottom-back one, so each strip closed on its original vertices. That left zero boundary edges and a positive signed volume.
Reusing profiles precisely is also a cleanup skill. To put the tier-one ornaments onto tier two, I didn’t eyeball a new sweep. I read the cross-sections from the tier-one solids’ end caps. Their shoelace areas matched the measured caps: 0.17031 against 0.17030. The sweep was accepted only when its volume matched section area times path length: 10.4441 m³ built against 10.4447 in theory. That volume check caught two wrong attempts where the sweep folded or ended up on the wrong side of the path.
Stage 4 · 26 August
Seating 2,758 people
The seat layout came from the chart, but the seats themselves needed a system. Real auditorium seats share armrests, so a row of N seats has N+1 armrests. I modelled one seat with only its right armrest, making it tileable, plus a separate end armrest. The geometry confirmed the idea: the standalone armrest is an exact copy of the seat’s own, offset in X by 10.192385 units, and that offset is the tile period.
So I baked both meshes into a normalized frame where the tile width is exactly 1.0. The seat’s armrest straddles +0.5, and the end armrest straddles −0.5. Object scale then equals row pitch in metres, and the end armrest drops in at the last seat’s exact transform. The script detected 79 parquet rows, swapped mesh data without moving any object, and capped every row.
The upper levels were harder, because the chart has no heights. In the dress circle, grouping seats by geometry instead of by name showed all 443 already sat on seven clean arcs. The block was simply floating. The top step is 1.6 m deep and carries two rows, while the layout had assumed one step per row. The terrace’s top faces also had inverted normals, so they didn’t register as floor. A radial ray-cast scan recovered the true staircase instead.
The balcony looked fine from the side and chaotic from above. Checking heights and symmetry wasn’t enough; the real reference was the terrace itself. The final fix extracted the step edges, where a horizontal tread meets a vertical riser, and filtered out radial edges so seats wouldn’t snap onto aisle steps. Each misaligned seat then took its position from the nearest step arc and its heading from that arc’s tangent.
| Balcony alignment to step arcs | Before | After |
|---|---|---|
| Median angle error | 0.88° | 0.86° |
| Worst seat | 88.6° | 7.44° |
| Seats over 8° | 26 | 0 |
| Front row distance to step | up to 3.47 m | 0.328 m |
| Area | Seats | End armrests |
|---|---|---|
| Parquet | 994 | 79 |
| Dress circle | 443 | 41 |
| Balcony | 814 | 142 |
| First tier boxes | 259 | – |
| Second tier boxes | 248 | – |
| Total | 2,758 | 262 |
Stage 5 · 14 September
Closing the room
With seats in place, the hall still had no back walls, no underside to the balconies and no ceiling beyond the ornate panel. I built all of it from the tier outlines themselves, one drawn line at a time.
- Box terraces. The real boxes are raked, so tier two got one 0.18 m step and tier one got two, placed halfway between seat rows. Partitions and armrests were cut along the same path and raised with their seats. The ends of the U got arcs fitted to my sketch.
- Walls from floor edges. The outer walls follow each floor’s own boundary points, so the curve has no gaps. Every wall tucks 5 mm past the floor edge to avoid z-fighting.
- Consoles and doors by face normal. I selected the partition end faces, and a copy of the tier-one console was aligned to each normal. Then 23 box doors were centred in the gaps between them.
- A funnel soffit. Under the balcony, the slope at each point around the U is the steepest that stays 0.30 m under every step, smoothed along the curve, just like the photo.
- Vaults from the proscenium curve. The vault ceilings reuse the exact 241-point curve of the arch soffit, extruded back over the balcony and forward over the stage, then trimmed to the walls.
Bending the ornate ceiling panel along the arch exposed a subtle bug. The deform moved only vertices, and the panel’s flat background was made of a few enormous faces, some spanning the full 22.5 m. Those stayed flat and cut across the curve, swallowing the gold mouldings that sit a few millimetres proud. The fix sliced every face wider than 20 cm into strips before bending. It touched only the plain background and left the ornament alone.
Stage 6 · 14–15 September
An ornament system, not ornaments
The tier parapets are the face of the hall. There are four of them, each with a different treatment, running for hundreds of metres of curve. Hand-modelling them was never an option, and building one parapet as a one-off would have meant doing it four times. So I built a library.
Spec first
I ran the design as a small multi-agent workflow. Two independent proposals, one photo-faithful and one classically buildable, were scored on likeness, architecture, consistency and triangle budget. The photo-faithful design won, 31 to 29.5 out of 40, and its best ideas were merged into a single spec.json. The spec is the contract. It defines the body as one closed profile polygon, a triangle budget per ornament unit, and the rules that keep everything aligned:
"band_fractions_photo": { "rail": 0.05, "top_cap": 0.08, "frieze": 0.42,
"cornice": 0.23, "brackets": 0.16, "bottom": 0.06 },
"tri_budgets": { "egg_dart_unit": 88, "dentil_unit": 24, "bead_reel_unit": 40,
"bracket": 560, "target_total_max": 900000 },
"alignment": "pilaster axes are the module: on every pilaster axis centre one bracket,
one dentil, one cornice egg ... between two axes use n = max(1, round(L/pitch))",
"min_feature": "visible parts >= 8 mm (about 6 mm/px at 10 m) ..."Generators, each with its own reviewer
Six generator modules each own one family of ornament: profiles and sweeps, egg-and-dart, dentils and bead-and-reel, scroll brackets, rinceau scrollwork, and the panel and bracket layout. Each was checked by an independent reviewer script before assembly. The assembler lays the pieces along the tier’s real path, recomputing the pitch on the parallel curve at each detail’s own depth, and passes its own suite of 61 tests. The library totals about 17,000 lines of Python, reviewers included.
Because every dimension lives in the spec, art direction became cheap. “The small columns are too flat” changed one number, from 1.8 cm to 5.5 and then 15.5 cm. The cap blocks, rail overhang and brackets all followed. Adding velvet cushions on each pilaster was a new closed solid that reused the rail’s section.
Four variations from one vocabulary
Once the parts existed, each tier became a recipe:
- Parquet wall cornice. The parapet’s lower section runs 84 m around the walls, with 102 lamps in a pattern of one lamp, then two empty bracket gaps.
- Second tier. No separators. Each bay is one wide panel bowed 8–10 cm toward the hall, with a bracket pair at each junction and a lamp centred under every bay.
- Dress circle. No brackets or scrollwork. Plaster separators and rows of long oval slots cut all the way through the body, so you can see the seats behind them.
- Balcony. The dress circle recipe without lamps, rebuilt on the floor’s true front edge.
Composing, not deforming: the arch corbels
The proscenium arches ended in open geometry where they met the walls. My first fix bisected and stretched an existing stage column, and it looked awful. Deforming a finished mesh destroys the proportions its detail depends on. The redo composed the corbel from the library’s parts, stacked to follow the photos: a dentil cornice, a rinceau frieze, a small egg-and-dart, three large S-scroll consoles (the parapet brackets scaled up), a bead-and-reel plinth, and a thin pilaster fading back into the wall. That became a standing rule for the project. New pieces are composed from the generators and never stretched from existing meshes.
The same parts dressed the rest of the ceiling. The side band went through four versions before it matched the photo: a scroll frieze, a divider, dentils, egg-and-dart, and a new reusable palmette row. The arch soffits got alternating coffers with rinceau and oval medallions, laid out along each arch’s own curve.
Stage 7 · 3 August – 16 September
Light only where a lamp exists
My first lighting pass used 118 lights and looked like an office: 138 kW with fill lights everywhere, so nothing fell off and nothing was dark. The fix was subtractive. I cut total power 3.4 times, dropped the fill from 350 W to 55 W so the under-balcony reads dark, warmed the house to about 2400 K, and switched to AgX Medium High Contrast.
Later I adopted a stricter rule: every light must come from a fixture you can see. The dome LEDs were read straight from the emissive faces of the ceiling mesh, with 96 in the inner ring and 40 in the outer, each light 6 cm below its lamp. Stage washes and front-of-house lights hanging in mid-air were deleted. Every new fixture shipped with its light, sharing one datablock per family so a whole ring can be tuned at once.
| Fixture family | Count | Source |
|---|---|---|
| Dome LEDs | 136 | Read from the ceiling’s emissive faces |
| Stage ceiling LEDs | 112 | Oval tray rings and front row |
| Parquet cornice lamps | 102 | One per three bracket gaps |
| Arch downlights | 102 | Two rows along the proscenium arches |
| Dress circle lamps | 59 | Set in the parapet band |
| Second tier lamps | 37 | One per bowed bay |
| Back beam LEDs | 28 | Behind the balcony ceiling beam |
| Balcony soffit domes | 26 | Outer ring, with 13 air vents inside it |
| Box door lamps | 23 | Above each tier-two door |
The seats got a velvet material with a sheen lobe, mottled colour and a fine pile bump. It was rebuilt in place on the one shared material, so about 2,700 seat objects updated at once. The hero panorama rendered in Cycles at 3840×2033 with up to 4,096 samples and 32 bounces on an RTX 3090, and took 61 minutes. One practical lesson from that render: a render launched from a script freezes Blender’s UI and shows no progress, so long renders go through F12.
Coda
Is it game-ready?
The scene renders 13.6 million triangles, but only 5.6 million are unique: 3,170 objects built from 167 distinct meshes. The seats alone account for 5.3 million rendered triangles, which becomes a few thousand once they are exported as instances. For Unreal Engine 5 that is a comfortable Nanite workload, as long as repeated pieces stay instanced and the procedural velvet is baked or rebuilt as an Unreal cloth-sheen material. Every Houdini asset already exports clean GLB with real-world scale, Y-up and patched materials. That is where the project goes next: into Unreal, with a MetaHuman opera singer on this stage.
What I’d tell another technical artist
Start from data, not a mood board. A seating chart gave me survey-grade positions for 2,864 seats and every wall for free. Look for the precise document hiding behind your subject.
Measure, then print the proof. Every builder printed reference-versus-model ratios. Arguments about whether something looks right end quickly when both numbers are on screen.
One note, one parameter. “10% wider” should be one edit. If a note forces you to touch many values, the parameterization is wrong.
Verify geometry with numbers. Boundary edges, signed volume, area before and after, ray-cast checks. A boolean that reports success can still be wrong.
Compose, don’t deform. Build new pieces from a vocabulary of generators. Stretching a finished mesh breaks the proportions its detail depends on.
Pick ornament your tools can carve. Gadroons beat bad acanthus. Choose the version of the design that the procedural method renders convincingly.
Gallery
The hall
Tools. Blender 5.2 with the Blender MCP bridge · Houdini 20.5 (hython, SOPs, VEX, Karma) · Python · Cycles with OptiX on an RTX 3090 · Claude Code as a scripting copilot.
Reference. Carnegie Hall’s public seating chart for the Isaac Stern Auditorium / Ronald O. Perelman Stage, a ticketing section map of the hall, and reference photographs of the hall. This is a personal study, not affiliated with Carnegie Hall.
Comments
Tags in this post
Keep reading
Rendering Brilliance
A visual tour of the cubemap-based diamond shader — how a faceted gemstone becomes a single texture lookup, and what that gets you. Eight interactive figures, plain-English asides, and the optics that hold it all together.
15 min · May 26, 2026
Parsing documents without uploading them
A procurement pipeline that never leaves the tab: PDF, DOCX and XLSX to editable Markdown, PP-OCRv6 on ONNX Runtime Web inside a module worker, and the canvas shims nobody warns you about.
12 min · Jul 7, 2026
Building a 3D ring configurator in Expo
The complete story of Lumière: loading GLBs on device, composing rings from parts at runtime, a custom refractive gem shader, porting the whole thing to the browser — and the audit that found a stranger's email address inside a binary before I made it public.
17 min · May 5, 2026
All tags