One city, three renderers¶
Valdrada renders the same city in three engines. The city is authored and simulated in the three.js client, the one place that decides what exists, where it is, its size and its class. A recorded take of a camera path is exported to OpenUSD, and Blender Cycles and Unreal Engine 5 render that export. Nothing flows back from an offline renderer to the client, and neither offline renderer adds, removes, moves, resizes or reclassifies anything: they differ in how the same objects look.
flowchart LR
W["three.js client<br/>tiles, simulation, film recorder"] -->|harvest.mjs| H["harvest folder<br/>geometry, materials, textures, lights,<br/>camera and movers per frame"]
H -->|"usd_write.py and its hooks"| U["take USD<br/>preview surfaces with bx tags,<br/>time-sampled vehicles, trains, signals, walkers"]
U -->|blender_take.py| B["Blender Cycles frames"]
U -->|ue_take.py| E["Unreal Engine 5 frames"]
L["phys_light.json"] --> B
L --> E
The three stages¶
Authoring and simulation (three.js). The client streams the compiled tiles, runs the traffic, transit and pedestrian
simulation and draws the frame in WebGL2. A shot is a camera path in tools/ad/shots.json; the film recorder
(tools/ad/record.mjs) plays it and writes the web take. Every change to the city, from a new building rule to a new
material, is made here first.
Export (OpenUSD). client/tools/ar34/export/harvest.mjs opens the client in a headless browser, plays the shot
the way the recorder does and saves the harvest: the geometry within 350 m of the camera path with a coarse city to
4 km, the materials with their shader uniforms and textures, the lights, the camera of every frame, and every vehicle,
train, signal lens and walker per frame. usd_write.py turns the harvest into the take's USD layers: the camera, the
moving layer (vehicles and trains as time-sampled point instancers with stable ids and velocities, the signal lenses'
state per frame), the static city with every mesh stored once and instanced, the materials, the lights and the trees.
Its hooks add what the client draws through dedicated shaders:
| Writer hook | Adds to the take |
|---|---|
usd_windows.py |
the tile facades as a UV-space bake (albedo, window mask, room emission) in place of their shells, and the window kit's material roles (bxwin_usd.json) |
usd_trees.py |
the street trees as species prototypes of the bxtrees2 set on the client's tree matrices |
usd_mat.py |
per-vertex primvars instead of material variants, and the bx tag on every material it can rebuild |
usd_peds.py |
the walkers as UsdSkel, and peds_<shot>.npz for Blender |
usd_ramps.py |
decks under vehicles that the client drives along undecked ramps (see below) |
Every material carries a UsdPreviewSurface, so any USD renderer can draw it. A material the writer recognises also
carries the bx tag in its customData: a JSON record of its family (kind), its pbrLib set name (set) for the PBR
library's surfaces, the web shader's uniforms and its texture files. The writer's coplanar pass (usd_coplanar.py)
separates surfaces that share a plane and lists the pairs it cannot resolve in bx_fix.json. The take folder is the
contract between the stages: the renderers read only the take's USD and the files beside it.
Rendering (Blender Cycles and Unreal Engine 5). blender_take.py imports the take into Blender 4.5 and runs the
Blender hooks before rendering with Cycles; ue_take.py prepares the take for Unreal, imports it in the editor's
command line mode and renders it with Movie Render Queue.
| Stage | Blender Cycles | Unreal Engine 5 |
|---|---|---|
| USD changes | none | ue_prep.py: per-vertex data packed into UV sets st1 to st3 (Unreal imports four UV sets), time-sampled instancers as animated transforms (Unreal reads an instancer at one time), ue_<shot>.json with the tags and the moving instances |
| Import | Blender's USD importer | the USD Stage importer in UnrealEditor-Cmd, meshes as Nanite |
| Materials | blender_nodes.py: one node-group builder per family |
ue_mat.py: each tagged material as an instance of its family's master (ue_project/Python/ue_masters.py), by the table ue_families.json |
| Trees | blender_trees.py: translucent two-sided leaves, bark |
ue_trees.py: Nanite foliage, the masters of ue_foliage.py, wind as world position offset |
| Facades | blender_windows.py: interior mapping of the rooms behind each window |
M_bx_facade and the window kit's roles in ue_families.json |
| Walkers | blender_peds.py: armatures and keyframed poses from peds_<shot>.npz |
the UsdSkel walkers, recoloured per walker |
| Light | blender_light.py |
ue_light.py |
| Output | frame_%05d.jpg, depth |
frame_%05d.jpg, a data pass of regions and depth (ue_frames.py) |
What reaches every renderer without renderer work¶
These are data in the take's USD, so a change to them in the client reaches Blender and Unreal at the next export:
| Change in the client | Why it needs no renderer work |
|---|---|
| Geometry and placement: buildings, streets, furniture, landmarks | exported as meshes and instancers with their transforms |
| Vehicles and trains | time-sampled instancers with ids, orientations and velocities; wheel spin and steer as time samples |
| Walkers | UsdSkel bodies and animations (Blender reads the same poses from peds_<shot>.npz) |
| Signals | the signal lenses' state per frame as time-sampled scales and visibility |
| Trees | usd_trees.py replaces each tree pool of the client by the prototypes of its species form (51 trees of 12 forms in bxtrees2) on the same matrices; forms the set lacks keep the client's trees |
| Materials of an existing family | a material of one of the seven families (pbr, plain, untrim, vk, ground, decal, sign) is rebuilt from its bx tag, so a new pbrLib set, a new texture or a changed uniform needs nothing else |
| Light | phys_light.json holds the exposure per time of day and the sky light's gain, read by both blender_light.py and ue_light.py; the sun, the street lamps and the vehicle lamps come from the harvest at the client's levels |
Physical light is the default in both renderers (--light phys): the visible sky dome is the only sky light, there are
no fill lights, and albedos are not trimmed. --light web selects the rig calibrated against the web takes, which the
colour checks use as a baseline.
What needs work per renderer¶
A new shader family, one whose uniforms usd_mat.py does not recognise, is ported once per renderer: a builder in
blender_nodes.py for Blender, a master in ue_masters.py with its row in ue_families.json for Unreal. Until a
renderer has the port, that renderer draws the family's materials with their standard preview surface (base colour,
roughness, metalness, normal and emission maps as the writer converted them), so the take still renders, with the
web's look reduced to what a preview surface can express.
frontend_coverage.py reports, for one take's USD, every material family and pbrLib set and whether each renderer
draws it with its own builder or master, with a high-quality set in Unreal, or on the preview surface. bx_render_all.mjs
prints its summary line for every shot of a batch:
uv run --no-project --with usd-core python client/tools/ar34/export/frontend_coverage.py ~/bx/u/t7ArchTrack.usda
For the golden hour shot t7ArchTrack the take has 1,654 materials: Blender draws 1,571 (95 %) with a builder and
Unreal 1,602 (97 %) with a master; the rest are untagged surfaces on the preview material. Of the take's 36 pbrLib sets,
18 have a high-quality set in Unreal.
Renderer-specific improvements¶
A renderer may improve how something looks, never what exists or where. The improvements built so far are in Unreal:
| Improvement | Where | What it changes |
|---|---|---|
| High-quality layered materials | tools/assets/fetch_hq.py, ue_families.json hq |
13 CC0 material sets at 4K (asphalt, sidewalk concrete, granite, brick by colour, stone, concrete, stucco, the viaducts' painted steel and rust) mapped per pbrLib set, per ground kind, per viaduct layer and per facade wall class; each set is tinted to its material's own mean colour and laid at its real size, with a macro tone, a detail normal and cavity grime as extra layers |
| Foliage | ue_project/Python/ue_foliage.py, ue_trees.py |
Nanite trees with a two-sided foliage master: per-instance and per-leaf hue and lightness, the inner crown's occlusion, transmission fitted to the Cycles takes, wind |
| Cinematic preset | ue_take.py --cine |
8 temporal samples over a 180 degree shutter, depth of field on the subject, bloom, grain, vignette and light volumetric fog |
Without them (BXUE_NOHQ=1, the first tree set, no --cine), an Unreal take draws every family from the same tags
and textures as Blender. Blender's interior mapping of the rooms behind the windows is an improvement of the same kind
in Cycles.
The ramp decks are an exception that lives in the shared writer. The client's road graph joins some elevated roads to
the street by ramps that its traffic follows but its road builder does not deck, so the harvest has vehicles driving
through the air. usd_ramps.py lays a deck under every such path in the export, asphalt on top and concrete on its
sides, so both offline renderers draw the same deck; the client still draws these ramps without one.
Checks that keep the renderers coherent¶
| Check | Tool | Fails a take when |
|---|---|---|
| Colour against the web take | client/tools/ar34/export/cyc_vs_web.py |
off-colour 16 px cells cover more than 0.4 % of the frame, or off-lightness cells more than 2 %, after matching exposure (Cycles and Unreal takes) |
| Regions against both references | client/tools/ar34/export/ue_check.py |
a region (sky, buildings, ground, vegetation, structure) of an Unreal frame leaves the CIELAB band of the web and Cycles takes, or of the Cycles take alone under physical light (--noweb), in more than a third of the frames |
| Temporal scan | tools/ad/temporal_scan.py |
flicker, z-fighting or shimmer between neighbouring frames over its limits |
| Coplanar audit | usd_coplanar.py, bx_fix.json |
more than 100 coplanar surface pairs remain in view |
| Camera clearance | tools/ad/clearance.mjs |
the lens path crosses a drawn surface, or a vehicle is off the road in view |
| Vehicles | tools/ad/record.mjs, tools/ad/trailerue_vehcheck.py |
parked vehicles stand inside each other in view (the recorder), or two vehicles of the harvest overlap in plan in view (the harvest check, which the offline takes need because they draw the harvest's traffic) |
bx_render_all.mjs runs the geometry check after the USD, a three-frame preview before the take and the take checks
after it, for Cycles (--target cycles, the default) and Unreal (--target ue) alike, together with the tree crown
flicker test (bx_leafcheck.py), which reprojects frames by the depth that both renderers write.
Adding a material family to all three renderers¶
- Write the shader in the client (
client/src/). The harvest saves its uniforms and textures with no change. - In
usd_mat.py, recognise the shader's uniforms and tag its materials with a newkindand the parameters the renderers need. - In
blender_nodes.py, add abuild_<kind>(mat, P)node-group builder and its entry in the builder table. - In
ue_project/Python/ue_masters.py, add the master toMASTERSand raiseVERSIONso the project rebuilds it; inue_families.json, add the family underfamilieswith its master and its parameter rows (each maps a tag key to a material parameter). - Export a take, run
frontend_coverage.pyon it to see the family with a builder and a master, and render it in both renderers throughbx_render_all.mjsso the checks above compare it with the web take.
A new set of the PBR library needs none of these steps: the pbr family reads the set's textures and uniforms from its
tag.
Adding an Unreal-only improvement¶
An improvement is a row bound to an existing key (a family, a pbrLib set, a ground kind, an asset prefix) that changes materials, textures, parameters or render settings. For a high-quality material set:
- Add the set to
SETSintools/assets/fetch_hq.py(CC0 or CC BY with its source page) and fetch it. - Map the pbrLib set (or ground kind, viaduct layer, facade class) to it under
hqinue_families.json. - Render the take with Unreal and compare it with the Cycles take of the same shot (
ue_check.py --noweb).
A change of master or render setting follows the same order: the row or option in Unreal's files, then a take and its checks. An improvement that would need a different object, position, size or count belongs in the client instead.
File map¶
| Path | Role |
|---|---|
tools/ad/shots.json, tools/ad/record.mjs |
shots and the web take recorder |
client/tools/ar34/export/harvest.mjs |
the harvest of a shot from the running client |
usd_write.py, usd_mat.py, usd_trees.py, usd_windows.py, usd_peds.py, usd_ramps.py, usd_coplanar.py |
the take's USD and its hooks |
phys_light.json |
the physical light table shared by both renderers |
blender_take.py, blender_render.py and blender_*.py |
the Blender take and its hooks |
ue_take.py, ue_prep.py, ue_mat.py, ue_light.py, ue_trees.py, ue_frames.py |
the Unreal take |
ue_families.json |
Unreal's table: families, asset rules, roles, regions, high-quality sets |
ue_project/ |
the Unreal project: BoundlessUE.uproject, Config/, the masters in Python/ |
bx_render_all.mjs |
the batch for either renderer, with its checks |
frontend_coverage.py |
the coverage report |
cyc_vs_web.py, ue_check.py, bx_leafcheck.py, bx_temporal.py |
the take checks and the Cycles temporal filter |
tools/assets/fetch_hq.py |
the CC0 material sets for Unreal |
The paths without a folder are in client/tools/ar34/export/. The guides Rendering in Blender Cycles
and Rendering in Unreal Engine 5 take one shot through each renderer.