DMX Channel Sources - towneh/vrsl-urp GitHub Wiki

DMX Channel Sources

How DMX reaches the fixtures as bytes rather than as a video frame, how to feed it from a Basis media player carrying DMX inside the stream or from Truss over OSC, how to frame a grid that arrives as picture, and how to write a source of your own.

The two DMX paths

VRSL's original path is a pixel grid: a video frame encodes channel values as colours, a CustomRenderTexture chain decodes it, and fixtures sample the result. It exists because a video frame was the only way to get DMX into a VRChat world.

A channel source skips all of that. Where the values arrive as bytes already, a component implementing IVRSLDMXChannelSource hands them to the light manager, the manager scatters them into a GPU channel buffer, and fixtures read the buffer. No grid, no capture camera, no CRT chain, and the precision loss of encoding bytes into pixels and back is gone.

The manager's DMX Input chooses between them: a grid input reads the pixel grid, and Stream data reads the channel source. A source assigned under a grid input waits on standby, bound and drained every frame so it is current the moment Stream data is chosen, but nothing reads it.

Which of the two is driving the fixtures at any moment, and what every channel is reading, is what DMX Monitor covers.

Feeding it from a Basis media player

Lighting data can ride inside a live video stream. Truss takes Art-Net from a desk and stamps each frame's DMX snapshot into the H.264 stream as SEI user data as the video passes through an RTMP relay. The data is part of the access unit, so it stays locked to the picture and arrives wherever the video arrives. The Basis media player raises every such message at the moment its frame is shown, and this package turns the ones that are Truss's into channel values.

The pieces:

BasisMediaPlayer ──OnUserDataReceived──▶ BasisUserDataToVRSLDMX ──IVRSLDMXChannelSource──▶ VRSL_DMXURPLightManager
   (raises every SEI user-data            (keeps Truss's UUID, verifies             (scatters the blocks into
    message, any UUID, at the              the record, hands over blocks)            the channel buffer)
    frame it belongs to)

Setting it up

Needs com.basis.mediaplayer in the project. com.cnlohr.cilbox is optional: with it the components are [Cilboxable] and can be used from sandboxed world scripts; without it they are plain components for a scene-owned player.

  1. A BasisMediaPlayer showing the stream, set up as for any other source.
  2. BasisUserDataToVRSLDMX (menu: VRSL → URP → DMX Config → Add Basis DMX Record Source (SEI)) on any GameObject, with Player pointing at that player.
  3. A VRSL_DMXURPLightManager in the scene. The component registers itself as the manager's channel source when enabled and clears the registration when disabled.
  4. Fixtures patched as usual. Universes are addressed the way a desk counts them: the stream's universe 0 is VRSL's universe 1.
  5. The manager's DMX Input set to Stream data. Under a grid input the source waits on standby.

Fields:

Field What it does
Player The player whose stream carries the data. Can be assigned or swapped at runtime.
minimumUniverses How many universes to size the buffer for before any data arrives. The count grows when a block names a higher universe, and each growth reallocates the manager's buffers, so set it to the show's size to avoid a resize on the first cue.
logDrops Log the first record dropped for each reason. Off by default, because a damaged stream would otherwise log at frame rate.

What to read when checking it works:

Property Meaning
RecordsDecoded Records decoded and handed on since enable. Climbs one per video frame on a stream carrying one record per frame.
RecordsDropped Records that failed a check and were not applied.
LastResult Why the most recent record was dropped, or Ok.
LastHeader The outer frame of the most recent decoded record: sequence, frame index, send time, carrier.
UniverseCount What the manager is sized for right now.

What to expect

  • Values are absolute, so a dropped or late record is corrected as soon as a later record carries the affected slots again; nothing accumulates. How soon that is depends on the sender. Truss's relay resends every universe it has heard in every record, so with it a dropped record costs one frame and a client joining mid-show is right within a frame. A sender that carries only the channels that changed needs to resend whole universes periodically, or a dropped change stays missing and a late joiner never sees channels that do not change again. The manager itself keeps only what it was told and starts from zero.
  • A damaged record is dropped, not applied. Each record carries a CRC over everything in it; one that fails, or whose framing does not add up, is counted and ignored rather than lit. On a show, "arrived broken" and "did not arrive" are different faults, and LastResult says which.
  • Partial snapshots are fine. A record may carry only the channels that changed. Blocks delivered between two frames accumulate, so nothing is lost to a busy frame, and the manager keeps every value it was last told.
  • Timing is the video's. The player raises each message when the frame carrying it is shown, so the lighting tracks the picture through whatever delay the path adds, rather than running ahead of it.
  • Each block carries its own age: how long before the record was sent that universe was last heard from. Universes are latched as their Art-Net packets arrive, and 44 Hz does not divide into a frame grid, so the manager uses the age per universe rather than assuming they were all sampled together.

What survives the network

SEI rides inside the video elementary stream. It survives any path that copies that stream through unchanged, which is what a remuxing CDN does (RTSP, MPEG-TS and RTMP egress all carry it intact, VRCDN's TS egress included). It does not survive a transcode: a path that re-encodes the video drops the lane entirely and silently, because the picture keeps working. A remux that runs bitstream filters over the video can also strip or rewrite it.

The same rule decides whether a recording keeps the lane. A recorder that copies the stream (ffmpeg -c copy, a server's recording feature, a remuxed capture) keeps every record, MP4 included: the MP4 sample holds the whole access unit, and the player's MP4 demuxer hands the SEI to the same scan as the TS path. A recorder that re-encodes (a capture of the screen, a "convert" step, an encoder recording its own canvas) loses it like a transcoding CDN does.

So when nothing lights:

Symptom Likely cause
RecordsDecoded stays at 0, RecordsDropped at 0 The lane is not in the stream at this end. Re-encoded somewhere on the path, or the relay is not injecting. truss-detect against the same URL tells you which.
RecordsDropped climbing, LastResult = BadCrc or BadMagic Something on the path rewrote the bytes. A bitstream filter, or a relay bug.
LastResult = BadPayloadMagic The records are Truss's but are probe records from a measurement run, not lighting data.
Decoded climbs, fixtures dark DMX Input isn't on Stream data, so the source is on standby; the DMX Monitor's header says so. Otherwise the patch: check UniverseCount covers the universes the desk is sending, and that fixtures are addressed in the right universe (desk universe 0 is VRSL universe 1).

The record

VRSLTrussDmx, in the core assembly, decodes the record and knows nothing about Basis. The layout is Truss's: a TRUSSDMX frame (version, carrier, sequence, send time, frame index, payload length, CRC-32 over everything before it) around a DMXS payload of blocks, each (universe, start slot, length, age µs, values), all integers big-endian, carried as SEI user_data_unregistered under the UUID b1f0a7d4-9c3e-4a52-8f61-2d7c5e0b93a8. Anything else that delivers the same bytes can feed the same decoder.

Feeding it from Truss over OSC

The same DMX can arrive without a stream. truss-relay --osc <this machine>:12100 sends every record it builds to an OSC listener as well as into the video: one message, /truss/dmx, with the record as its single blob argument. The payload is the one the stream carries, so the fixtures show what viewers would get, universes, ages and budget rotation included, and a VJ can check what they are sending with nothing else running. While an encoder is connected the lane carries the payload of every record the stream carries, at the video's frame rate; with none it builds records from the desk's Art-Net on its own clock, 30 a second unless told otherwise.

Setting it up

No Basis packages are needed; the source is in the core assembly.

  1. A VRSL_DMXURPLightManager in the scene and fixtures patched as usual. Universes count the way a desk counts them: the stream's universe 0 is VRSL's universe 1.
  2. VRSLTrussOscDmxSource on any GameObject (menu: VRSL → URP → DMX Config → Add Truss OSC DMX Source). It listens in Play mode on port 12100, on every adapter.
  3. truss-relay --artnet --osc <this machine>:12100, with the desk sending it Art-Net. On the same machine that is 127.0.0.1:12100.
  4. The manager's DMX Input set to Stream data. Under a grid input the source waits on standby.
Field What it does
port The UDP port to listen on; the relay's --osc must name the same one. A change takes effect the next time the component is enabled.
listenAddress 0.0.0.0 hears every adapter, which a relay on another machine needs; 127.0.0.1 hears only this machine.
minimumUniverses, logDrops As for the Basis source above.

In Play mode the inspector shows a Live block: whether the socket is open, datagrams received and how many were not our message, records decoded and dropped, the last result and the universe count, with a note saying what to check when nothing is lighting. RecordsDecoded, RecordsDropped, LastResult, LastHeader and UniverseCount read as they do on the Basis source; DatagramsReceived, DatagramsIgnored, Listening and LastError are this source's own.

What to expect

  • Only one message shape is read. A bundle, a message under another address or with another argument type, or something that is not OSC at all is counted as ignored and never reaches the decoder. Another sender on the port cannot take the source down or light a fixture; it shows as ignored datagrams climbing while records do not.
  • A port already held is reported, not thrown. Listening is false, LastError says why, and one error names the address and port. Give the relay and the source a port of their own, or stop whatever holds it.
  • The lane's own sequence. The relay numbers the OSC lane's records from the first it built for it, under carrier 6, so LastHeader scores loss and order on the lane rather than on whatever the stream was doing. truss-detect osc:// and truss-dmxmon osc:// read the same port from the command line when the question is whether the relay is sending at all.
  • Everything else is the record's. Values are absolute, a damaged record is dropped and counted, partial snapshots accumulate, and each block carries its own age, exactly as on the stream.

Framing a grid into the RAW RT

BasisVideoRenderTextureOutput takes a player's output frame and writes the grid into the RAW grid RenderTexture the CRT chain reads, replacing the capture camera that used to film a screen quad. It samples the source at four UVs, one per corner of the destination, which between them express crop, rotation, flip and shear.

Its defaults are the identity mapping: bottom-left (0,0), bottom-right (1,0), top-right (1,1), top-left (0,1). That means "the source frame already is the RT's content, edge to edge, the same way up". It is a neutral starting point rather than a working one, and two things usually stand between it and a real stream.

The grid is a strip inside a larger frame

A stream carrying a grid rarely carries only the grid. The corners crop to the part of the frame the grid occupies. Nothing surprising here, and the inspector's drag handles are enough on their own.

Horizontal mode turns the channel space on its side

The same 1560 addresses, three universes, are laid out differently in the two spaces. In the RAW grid RT, which is 13 cells wide and 120 tall, channel c sits at column (c-1) % 13 and row (c-1) / 13 counting up from the bottom: each row is one 13-channel fixture, reading left to right. In a horizontal grid node's picture, which is 120 cells wide and 13 tall, each column is one fixture, reading top to bottom: channel c sits at column (c-1) / 13 and row (c-1) % 13 counting down from the top. A 1080p show carries the strip across the bottom 208 rows of the picture.

So the picture goes into the RT a quarter turn anticlockwise, which is how VRSL's own reader camera films it: the camera is rolled so the RT's left edge reads along the strip's top. A reflection about the diagonal also fills the RT and lights the rig, but reads every fixture's 13 channels backwards, pan from the speed channel and colour from the strobe and dimmer. The turn the other way reverses the fixtures as well.

Vertical mode needs no turn. Its picture is 13 cells wide and 67 tall, which is 871 addresses rather than horizontal's 1560, so it carries about one and two thirds universes where horizontal carries three. Fewer addresses, but numbered the way the RT numbers them: channel c at column (c-1) % 13 and row (c-1) / 13, filling the bottom 67 rows of the RT's 120 and leaving the rest unwritten. Cropping the frame to the grid is the whole job. The turn exists only because horizontal mode lays a larger channel space on its side to make a wide strip.

Working it out for a given stream

Take each corner of the destination, ask which channel sits there, then find where that channel is in the picture. For a 1920x1080 frame carrying the grid as a 1920x208 strip along its bottom edge, so v runs from 0 to 208/1080:

Destination corner Channel there Where it is in the picture UV
Bottom-left 1 top-left of the strip (0, 0.1925926)
Bottom-right 13 bottom-left of the strip (0, 0)
Top-right 1560 bottom-right of the strip (1, 0)
Top-left 1548 top-right of the strip (1, 0.1925926)

These are the corners VRSL → URP → DMX Config → Add Basis DMX Video Output (Horizontal) sets. A stream that puts the strip somewhere else keeps the same pattern: the bottom pair share a u, with bottom-left on the strip's top edge. Bottom-left on the bottom edge is the reflection, and four corners whose bottom pair share a v are a crop with the turn missing.

The inspector will do it for you: drag the strip's edges first, then press Turn 90° Anticlockwise. The turn is a permutation of the four corners, so it applies to a crop already dragged rather than discarding it.

Where a world still has its old reader camera and the screen it filmed, Bake Framing From Camera works the corners out from the two. It reads the screen mesh's own UVs, so VRSL's stock screen (two units across, mirrored) bakes as it is, and a camera that frames a little past the screen's edges gives corners a little outside the picture, as it filmed.

The defaults are the identity mapping, because the crop depends on the stream and the turn on the layout. On a real stream the preview then shows the whole frame, which is plainly not a grid.

Checking it

None of this is visible. Identity, either turn and the reflection all fill the RT and all light the rig, so the fixtures moving is no evidence. Judge it against content whose values are a known function of their own address: a ramp that marches with the frame gives every channel a value you can predict from its number, so a mis-framing shows up on any channel rather than only where a distinctive value happens to land. The content has to be laid out the way a show is, channel 1 on the strip's top row, or a framing that matches the content's own mistake passes. Rows N24 and N26 in TESTING.md do exactly that, and N26 reads the RT back directly, which is what separates a framing fault from a decode-chain one.

Following the manager's layout

With Follow Manager Layout on, the output writes into the raw grid of whichever layout the manager's DMX Input reads (the texture that layout's main decode texture samples) and frames it with that layout's own corners, so a world whose events send different layouts switches the framing with the input. The inspector's layout selector picks which set of corners the drag view edits; Copy the single corners seeds one from the corners above, and Bake Framing From Camera bakes into the layout selected. On a switch it blanks the grid it leaves. If a layout's main texture is one of your own with no raw grid to find, it writes the DMX Grid RT instead and says so once.

Following is off by default, and then the output writes the DMX Grid RT with its one set of corners. Either way it writes nothing while the manager reads Stream data, and writes again in the first frame back on a grid.

Choosing between them

Both lanes can ride the same stream and arrive on the same frame, so choose by what the path between the desk and the fixture may do to the data. DMX Sources compares the two; beyond its table:

Records (SEI user data) Pixel grid
Costs bitrate About 1.7 kB a frame for 3 full universes, near 400 kbit/s at 30 fps. Send only the channels that changed and it drops in proportion Nothing extra, but the strip takes bits the picture would have had
Values reach fixtures Straight into the channel buffer Through the interpolation CRT, so damped and a frame behind

Where the grid loses data, precisely

Two things stand between a grid reading and the value a desk sent.

TV range does not hold 256 values. A normal encode carries luma in 16 to 235, which is 220 levels for 256 DMX values, so some values are not distinguishable once encoded and a channel reads back up to a unit or two low. Tagging the stream PC range round trips exactly, but only on a decoder that honours the tag, so check that before relying on it.

Colour space will curve the values if any stage disagrees. Sampling an sRGB source into a linear target in linear colour space converts, and nothing converts back: a value of 104 arrives as 35, and 104 and 105 both arrive as 35, so no later stage can recover them. BasisVideoRenderTextureOutput handles this for the target it writes. Any other route into the grid RT has to.

Which to use

Use the records where the path is yours end to end and truss-detect against your own egress shows it carries them: they are the exact bytes, and a dropped record is counted. Use the grid where a CDN may re-encode or the player can't surface user data; it goes everywhere video goes.

Sending both is a reasonable answer for a stream that has to work on paths you do not control, but read the next section before treating it as a safety net: nothing falls back by itself. DMX Monitor names which of the two is live at any moment, which is the thing to check first when the values look wrong rather than absent.

When both are present

DMX Input decides, and nothing else does. Under Stream data the manager publishes the channel buffer and decodes no grid; under a grid input it publishes a channel count of zero and the source waits on standby. Every reader then makes the same test on that count, non-zero reading the buffer and zero reading the grid: the compute's MainChannel for the realtime lights, and the fixture shaders for everything they draw from DMX, the par and blinder bodies, flashers, light bars, strobes, lasers, lens flares, the discoball and the mover heads included. A realtime light and a shader-driven fixture on the same channels therefore never read different sources, and they share the movement smoothing, strobe timing and gobo spin the manager keeps per channel. BasisVideoRenderTextureOutput is not a channel source: it writes the RAW grid RT under a grid input and nothing under Stream data.

Under Stream data that holds whether or not any records have arrived. BasisUserDataToVRSLDMX reports at least minimumUniverses from the moment it is enabled, so the manager allocates that many universes and the channel count never falls to zero. On a path that re-encodes the video, where no record ever arrives, the buffer is a block of zeros and the fixtures read it: the rig goes dark. With the source switched off or missing, the rig is dark as well, and the manager says so in the Console after a few seconds. A lane that delivers and then stops is different again, since the manager keeps what it was last told, so the rig freezes on the last snapshot instead.

Falling back to the picture is the event's choice, made by choosing a grid under DMX Input, and it has to be the same choice on every client, so the package never makes it. A world that wants the picture as a backstop decides when (for example on RecordsDecoded staying at zero) and sets the input through its synced script, as DMX Sources describes.

Writing your own channel source

Any MonoBehaviour implementing IVRSLDMXChannelSource can drive the rig:

public interface IVRSLDMXChannelSource
{
    int UniverseCount { get; }
    bool TryGetBlocks(out NativeArray<VRSLDMXBlock> blocks, out int blockCount,
                      out NativeArray<byte> values);
}

The manager calls TryGetBlocks once per frame. The contract:

  • Return false when nothing new arrived. That means "keep what you have", not "stop publishing"; the manager holds the last value it was told for every slot. A source that re-hands the same blocks every frame would re-apply them, and with them their ages, for time that did not pass.
  • Blocks are runs of consecutive slots from one universe, addressed the way a desk is: a 0-based universe and a 0-based slot within it. The manager applies VRSL's 520-slot stride itself, so a source never needs to know about it.
  • Both arrays are borrowed for the call. The manager copies out of them during TryGetBlocks and never touches them afterwards, so the source may reuse or resize them the moment the call returns.
  • Values are absolute and idempotent. A partial snapshot corrects the slots it covers and leaves the rest alone.
  • UniverseCount sizes the buffer. Universes are 0-based indices and this is a count, so it must be max(block.universe) + 1: a source naming universes 0 and 1 with a count of 1 has universe 1 dropped. It should not change frame to frame; every change reallocates.
  • A block naming a universe at or above UniverseCount is dropped; a run passing slot 512 or the end of the values array is truncated. Both quietly unless the manager's debug logs are on.
  • Register with VRSL_DMXURPLightManager.Instance.ChannelSource = this in OnEnable, and clear it in OnDisable if it still points at you. The manager can come up after your component, so check again in Update if your source may enable first.

VRSL_SyntheticDMXChannelSource (Runtime/Scripts/) is the reference implementation: a desk that is not there, generating patterns on the CPU. Its Ramp pattern, where every channel holds a known function of its address, is what the VRSL → URP → DMX Config → Validate DMX Channel Buffer menu compares against, which makes it the quickest way to prove a new path end to end.

⚠️ **GitHub.com Fallback** ⚠️