External Scene Seams

rfgen generates radio data. Other tools render pictures of places: NVIDIA Omniverse, Blender, a game engine, a robotics simulator. This page is about the four places where those two worlds touch, what crosses each one, and what deliberately does not.

If you have never worked with 3D scene formats, one term is worth having up front. USD (Universal Scene Description) is an open file format for describing a 3D scene: what objects exist, where they are, how they move over time. Pixar wrote it, and it is what Omniverse reads. A .usda file is USD in plain text, so you can open one in a text editor and read it. That readability is why this repository writes .usda and not the binary form: a text file can be compared line by line in a test, and a difference in it is something a human can look at.

Two more USD words, because everything below uses them. A prim is USD’s node in the scene tree. It has a path such as /World/Systems/system_000, a type, and a set of named attributes, and prims nest inside each other to make the hierarchy a viewer shows you. A stage is the composed scene a USD file opens as: the tree of prims plus the file-level settings that describe the whole scene, such as its units, its up direction, and its time axis.

There are four seams, and they run in two directions.

Seam

Which way it runs

What crosses it

What ships

The asset layer

Naming

A 3D file’s identity: its kind and its digest

Yes

The ingestion boundary

Inward

A 3D file becoming a scene the propagation engine solves in

A refusal, not a converter

The clock mapping

Shared

The scene clock as a stage time axis

Yes

The exporter

Outward

A scene plan becoming a .usda stage

Yes

The short version: rfgen can name any of these formats, can write USD, and cannot read USD. That asymmetry is deliberate and the sections below each say why.

If you only want the runnable half, it is one command. rfgen export-plan writes a scene plan as a .usda stage, either from a configuration it mints the plan from or from a plan artifact a generation run already wrote:

pip install 'rfgen[usd]'
rfgen export-plan --config-dir conf --run-seed 42 --output scene.usda

What you run below has the rest, and the option list is in Reference / Command-Line Interface.

The asset layer: naming a file is not loading it

A scene that uses ray tracing needs an actual 3D model of a place, and that model lives in a file somewhere. rfgen records which file, by a rule that never depends on whether the machine doing the recording could open it. The rule has two parts.

The kind is what sort of file it is, derived from the URI’s suffix. A .usd, .usda, or .usdc is a USD stage; a .usdz is a USD package; an .xml is a Mitsuba bundle, which is what the shipped ray tracer reads; and anything the table does not recognize falls through to a Mitsuba bundle too, which is what keeps configurations written before the USD rows existed on the kind they already had. The .xml row and the fall-through reach the same kind by different routes, and the difference matters for the explicit kind field below: .xml is a suffix the table recognizes, so a declared kind that contradicts it is refused. Suffix sniffing is a weak rule and the reference page says so, but reading the bytes is not available for a gs:// or https:// URI at all, so the suffix is what there is. An explicit kind field exists for the case the suffix cannot decide, that is, a URI carrying no recognized geometry suffix. It may only fill that gap: a declared kind that contradicts a suffix the table does recognize is refused rather than honoured, because a configuration that disagrees with itself about what a file is would send the digest down the wrong branch silently.

The digest is a content hash, and what it covers is decided by the URI’s scheme first and only then by the kind. For a gs://, s3://, or https:// URI no bytes are read at all, so the digest stands in for the asset by covering the URI and the kind, and the reference is marked as not content-addressed: its digest identifies the name it was given, not the bytes it points at. For a built-in Sionna scene it covers the installed Sionna version, because that is what decides the geometry. Only for a local file does the kind decide, and then a self-contained file is hashed whole while a file that references other files, which both a Mitsuba bundle and a USD stage do, is hashed over its root file only. The reference page states that last limitation plainly rather than implying a stronger guarantee: editing a referenced mesh without touching the root produces a different world under an unchanged digest.

What naming a USD asset gets you today is a correct kind and a correct digest in the provenance of every record generated from that scene. It does not get you a loaded scene. That is the next seam.

The ingestion boundary: a refusal is a contract

To ray-trace radio propagation through a place, the geometry has to reach the solver. The shipped path is Sionna RT on top of Mitsuba, and the installed Mitsuba cannot read USD in any spelling. So a USD asset handed to the shipped ingest is refused, by name, before any engine work begins.

The refusal is the deliverable. It could have been a converter, and the reason it is not is worth understanding because it is a physics argument rather than an engineering one.

Converting a USD stage into something a radio solver can use means two jobs. The first is geometry: turning surfaces into triangles, which OpenUSD largely does for you. The second is materials, and it has no answer. A radio material in Sionna is described by an ITU material type, meaning one of a small standardized catalogue of building materials with tabulated electrical properties, plus a thickness. From those the solver derives how radio waves reflect off the surface and how much passes through it. A USD stage carries visual materials instead: colour, roughness, metallicity, the parameters a renderer needs to make a picture. There is no function from one to the other. A wall that looks like concrete in a render might be plasterboard, and the two have very different permittivity, which is how strongly a material stores and redirects an electric field and therefore how much of a wave bounces off rather than passing through.

A converter would therefore have to invent a radio material for every surface, and an invented material silently changes every reflection coefficient, every path loss, and every delay spread in the generated corpus: how much of a wave bounces, how much of it is lost on the way, and how smeared in time the arrivals are. Nothing downstream would report an error. The dataset would simply be wrong in a way that looks fine.

So the boundary ships as an extension point with a precise refusal: a named contract, a registry group a third-party ingest can register into, and an error that names the ingest, the kind it was handed, the URI, and the kinds that ingest does read. When someone does the materials work and can defend it, it plugs in here. Until then, the honest answer is a refusal that says which question is unanswered.

The clock mapping: two ways of saying when

A scene plan carries a clock: an origin in seconds since the Unix epoch, and a duration. A USD stage carries a time axis made of time codes, which are just numbers, plus a timeCodesPerSecond rate that says how many of them make a second. Animation in a stage is expressed in time codes.

Mapping one onto the other is arithmetic, and the only interesting decision is where to put zero. rfgen puts startTimeCode at 0.0 and carries the epoch separately, as a string, in the stage’s own metadata under rfgen:timeOriginS.

Two consequences follow, and both matter to anyone joining a rendered frame back to a radio capture:

  • A time code is an offset from the start of the scene, not an absolute instant. Reading one back to a wall-clock time needs the origin from the metadata as well.

  • The epoch is a string on purpose. An epoch-scale timestamp in a double-precision float has roughly a quarter-microsecond of resolution left, and a formatter that rounds it is a formatter that moves the whole capture. Storing Python’s own exact representation of the number and parsing it back makes the round trip exact by construction rather than by the good behaviour of a formatter this repository does not own.

The rate itself is a knob, not a constant. The default makes one time code one millisecond. A plan whose instants are binary fractions of a second wants a different one: an event boundary at 0.0625 s is a whole number of time codes at 1024.0 (code 64) and is not one at 1000.0 (code 62.5). That is a real choice for a real plan, so the exporter takes the rate as a parameter and the command line exposes it.

An event boundary that does not land exactly on a whole time code is neither rounded nor refused. Rounding would move an event; refusing would reject a legitimate plan over a cosmetic property of a viewer’s scrub bar. Instead the stage reports the worst-case gap it contains, in seconds, under rfgen:maxIntegralTimeCodeResidualS, so a consumer can decide for itself whether that gap matters.

The exporter: a plan becomes a stage

This is the seam that runs outward, and it is the one that works end to end. A scene plan (see Scenes for what one is and where it comes from) becomes a .usda file that Omniverse, or anything else that reads USD, can open. “Minted” is this repository’s word for realizing one: a plan is derived deterministically from an authored template and a seed, so the same template and seed always give the same plan.

Two of the plan’s own terms appear on the stage and are worth having. A system is one radio installation the plan places: a pose, and the transmit and receive antenna element positions that go with it. A target is a moving point the plan places for a radar system to see.

The stage is a projection of the plan. It is not the world. This is the sentence to carry away from this page, because the artifact invites the opposite reading. Open one of these files in a viewer and you see prims with transforms on them, arranged in a hierarchy, some of them moving. That looks like a scene. It is not one. What the stage contains is:

  • One transform per planned system, at its pose, with a ReceiverPose child transform always authored. The plan currently requires that pose to equal the system’s own, so the child looks redundant today; it exists so that the day the two differ is a change of values rather than a change of encoding, and it resets the transform stack because the plan stores that pose absolute.

  • One transform per planned target, with exactly two position samples, at the start and end of the clock, because a target moves at constant velocity and two samples on a straight line is the whole trajectory. That is exact only if the reader honours linear interpolation, and a file cannot enforce it: interpolation is chosen when a stage is opened. So the stage declares the requirement in rfgen:trajectoryInterpolation rather than claiming a property it does not own.

  • Stage metadata: the plan’s hash, the schema version, the stage convention with its version number, the rotation convention, the time origin, the declared interpolation requirement described just above, the event identifiers and their system ids with their start and stop time codes, and the residual above.

And what it does not contain is longer and more important. No geometry: not one mesh, not one bounding volume, not one primitive shape. Every prim that carries data is a bare transform, so a system is a coordinate frame rather than an antenna, and a target is a moving point rather than a scatterer with a size. No materials, no cameras, no lights, so opening the file in a viewer shows an empty viewport with a handful of transform gizmos, which is exactly the right picture of what a plan knows. It does not pull in the world asset from the first seam. And it carries no radio quantity at all: no path, no delay, no power, no capture.

Those live in the generated record, and rfgen:planHash is the join between the two. Given a stage and a dataset, that one string tells you which records came from this plan.

Two per-target numbers do appear on the stage and are worth a warning, because they are the ones most likely to be misread. rfgen:rcsDbsm is the plan’s constant radar cross-section parameter: how strongly a target reflects radar energy, in decibels relative to one square metre. For a real object that number varies strongly with viewing angle and with frequency, which is exactly why a single constant is a modelling input rather than a measurement. rfgen:phaseDeg is the plan’s constant scattering-phase parameter and carries the identical warning. Both are modelling inputs the plan was authored with. Neither is measured, angle-resolved, or frequency-dependent, and there is no frequency anywhere in the stage to resolve them against.

Two conventions are pinned, and both are stamped

Sionna works in a Z-up world measured in metres. USD has no such default: a stage that does not say otherwise is read as Y-up in centimetres. Writing a stage without stating the convention would therefore hand a consumer a scene lying on its side at a hundredth of its size. Every exported stage states both explicitly, and stamps the convention it used, with a version number, so a consumer can check rather than assume.

Rotations are the same shape of problem one level down. There are many conventions for turning three angles into an orientation, they disagree, and the disagreement is invisible until something points the wrong way. The stage names the one it used.

What you run

The exporter is reachable from the command line as rfgen export-plan, and it takes exactly one plan source. --config-dir composes a configuration and mints its plan template, which is the path to take when the plan does not exist yet:

rfgen export-plan --config-dir conf --run-seed 42 --output scene.usda

--plan-file reads back the artifact a generation run already wrote, which is the path to take when you want the stage for a dataset you have:

rfgen export-plan --plan-file artifacts/plans/scene-000/scene-plan.json \
  --output scene.usda

Naming both is refused, and so is naming neither. The full option list and the exit codes live in Reference / Command-Line Interface; the contract, the refusals, and the full list of what a stage carries live in Reference / API / Scene.

Writing USD needs the optional usd extra, which pulls in OpenUSD itself. Without it the command refuses and names the extra. That refusal is a declared boundary rather than a bug: OpenUSD publishes no wheel for 64-bit ARM Linux or for musl-based distributions, so a worker on one of those cannot install the extra without building OpenUSD from source. The full platform table is in the reference page’s platform note.

See Also