Add StatePayload and serialization helpers for web viewer

PiperOrigin-RevId: 956400340
Change-Id: Ibe049afe10eca85e5fb6d718a78863a641b864bb
This commit is contained in:
Matija Kecman
2026-07-30 03:00:11 -07:00
committed by Copybara-Service
parent 6a7a723093
commit c2e95b4161
3 changed files with 371 additions and 0 deletions
@@ -0,0 +1,171 @@
// Copyright 2026 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Wire-format implementation for the web viewer's state payload (see
// state_payload.h). Compiled into BOTH sides of the wire: the state_payload
// pybind module (serializer, via state_payload_py.cc) and the wasm
// web_client (parser, via web_client_session.cc). It must therefore stay
// free of python- or browser-specific dependencies.
#include "state_payload.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
#include <mujoco/mujoco.h>
namespace mujoco::studio {
namespace {
// Appends raw bytes to the payload buffer.
void AppendBytes(std::vector<std::byte>& buffer, const void* data,
size_t size) {
const std::byte* bytes = static_cast<const std::byte*>(data);
buffer.insert(buffer.end(), bytes, bytes + size);
}
// Appends a complete [u32 tag][u32 size][payload] block.
void AppendStateBlock(std::vector<std::byte>& buffer, uint32_t tag,
const void* data, size_t size) {
StateBlockHeader block_header{tag, static_cast<uint32_t>(size)};
AppendBytes(buffer, &block_header, sizeof(block_header));
AppendBytes(buffer, data, size);
}
// Serializes the render state (exactly kRenderStateSize bytes) into `ptr`.
void SerializeRenderStateInto(std::byte* ptr, const mjvCamera& camera,
const mjvPerturb& perturb,
const mjvOption& vis_options, const mjOption& opt,
const mjVisual& vis, const mjStatistic& stat,
const std::vector<uint8_t>& render_flags) {
memcpy(ptr, &camera, sizeof(mjvCamera));
ptr += sizeof(mjvCamera);
memcpy(ptr, &perturb, sizeof(mjvPerturb));
ptr += sizeof(mjvPerturb);
memcpy(ptr, &vis_options, sizeof(mjvOption));
ptr += sizeof(mjvOption);
memcpy(ptr, &opt, sizeof(mjOption));
ptr += sizeof(mjOption);
memcpy(ptr, &vis, sizeof(mjVisual));
ptr += sizeof(mjVisual);
memcpy(ptr, &stat, sizeof(mjStatistic));
ptr += sizeof(mjStatistic);
// Pack render flags (mjNRNDFLAG bytes).
memset(ptr, 0, mjNRNDFLAG);
for (size_t i = 0; i < mjNRNDFLAG && i < render_flags.size(); ++i) {
ptr[i] = static_cast<std::byte>(render_flags[i]);
}
}
} // namespace
size_t MaxStatePayloadSize(size_t physics_bytes) {
return sizeof(StatePayloadHeader) + 3 * sizeof(StateBlockHeader) +
(sizeof(int32_t) + physics_bytes) + kRenderStateSize +
kMaxExtraGeoms * sizeof(mjvGeom);
}
std::vector<std::byte> SerializeStatePayload(
uint32_t model_crc32, int32_t physics_spec, const void* physics,
size_t physics_bytes, const mjvCamera& camera, const mjvPerturb& perturb,
const mjvOption& vis_options, const mjOption& opt, const mjVisual& vis,
const mjStatistic& stat, const std::vector<uint8_t>& render_flags,
const mjvGeom* extra_geoms, size_t extra_geom_count) {
extra_geom_count =
extra_geom_count > kMaxExtraGeoms ? kMaxExtraGeoms : extra_geom_count;
std::vector<std::byte> buffer;
buffer.reserve(MaxStatePayloadSize(physics_bytes));
StatePayloadHeader header;
header.nblocks = extra_geom_count > 0 ? 3 : 2;
header.model_crc32 = model_crc32;
AppendBytes(buffer, &header, sizeof(header));
// Physics state: [i32 spec][mjtNum values...].
StateBlockHeader physics_header{
kTagPhysicsState, static_cast<uint32_t>(sizeof(int32_t) + physics_bytes)};
AppendBytes(buffer, &physics_header, sizeof(physics_header));
AppendBytes(buffer, &physics_spec, sizeof(int32_t));
AppendBytes(buffer, physics, physics_bytes);
// Render state, serialized into place.
StateBlockHeader render_header{kTagRenderState,
static_cast<uint32_t>(kRenderStateSize)};
AppendBytes(buffer, &render_header, sizeof(render_header));
const size_t render_offset = buffer.size();
buffer.resize(render_offset + kRenderStateSize);
SerializeRenderStateInto(buffer.data() + render_offset, camera, perturb,
vis_options, opt, vis, stat, render_flags);
// Extra geoms (only when present).
if (extra_geom_count > 0) {
AppendStateBlock(buffer, kTagExtraGeoms, extra_geoms,
extra_geom_count * sizeof(mjvGeom));
}
return buffer;
}
bool ParseStatePayload(const void* data, size_t size, StatePayloadView* out) {
const std::byte* bytes = static_cast<const std::byte*>(data);
if (size < sizeof(StatePayloadHeader)) return false;
StatePayloadHeader header;
memcpy(&header, bytes, sizeof(header));
if (header.magic != kStatePayloadMagic) return false;
if (header.version != kStatePayloadVersion) return false;
out->model_crc32 = header.model_crc32;
size_t offset = sizeof(StatePayloadHeader);
for (uint16_t i = 0; i < header.nblocks; ++i) {
if (offset + sizeof(StateBlockHeader) > size) return false;
StateBlockHeader block;
memcpy(&block, bytes + offset, sizeof(block));
offset += sizeof(StateBlockHeader);
if (offset + block.size > size) return false;
const std::byte* payload = bytes + offset;
switch (block.tag) {
case kTagPhysicsState:
if (block.size < sizeof(int32_t)) return false;
memcpy(&out->physics_spec, payload, sizeof(int32_t));
out->physics = payload + sizeof(int32_t);
out->physics_bytes = block.size - sizeof(int32_t);
break;
case kTagRenderState:
if (block.size != kRenderStateSize) return false;
out->render_state = payload;
break;
case kTagExtraGeoms:
if (block.size % sizeof(mjvGeom) != 0) return false;
out->extra_geoms = payload;
out->extra_geom_count = block.size / sizeof(mjvGeom);
break;
default:
break; // Unknown tag: skip.
}
offset += block.size;
}
return true;
}
} // namespace mujoco::studio
@@ -0,0 +1,128 @@
// Copyright 2026 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This file defines the serialization format for the web viewer's browser
// client render payload containing the data needed so that the browser can
// render the scene using the following call:
//
// Render(model, data, perturb, camera, vis_options, width, height, extra_geoms)
//
// The arguments come from the Python process:
//
// * model : fetched once over HTTP as /model.mjb; its runtime-mutable
// parts (opt/vis/stat) are re-sent in the render state block.
// * data : streamed as the physics state vector (mjSTATE_INTEGRATION);
// the browser recomputes the rest via mj_setState/mj_forward.
// * width/height: the browser canvas size.
// * extra_geoms : optional variable-size kTagExtraGeoms block.
// * ... : the rest of the arguments are sent as a fixed-size block
//
// The payload (SerializeStatePayload) is a sequence of tagged blocks:
//
// [StatePayloadHeader][u32 tag][u32 size][payload]...
//
// The payload is serialized by Python, sent over the /state WebSocket, and
// parsed by the browser.
//
// TODO(matijak): Try shrinking the physics block: float32 (or quantized) values
// instead of doubles, and/or delta-encoding against the client's last-acked
// payload. The /state ack (web_server.py) tells the server which snapshot each
// client last applied, which is the baseline that delta compression needs. For
// 100humanoids.xml the payload is ~181 KB of doubles and dominates slow links.
#ifndef MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_STATE_PAYLOAD_H_
#define MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_STATE_PAYLOAD_H_
#include <cstddef>
#include <cstdint>
#include <vector>
#include <mujoco/mujoco.h>
namespace mujoco::studio {
// "MJWS" as little-endian bytes. This magic constant identifies the
// StateServer WebSocket payload header and helps detect malformed or
// misrouted messages.
constexpr uint32_t kStatePayloadMagic =
'M' | ('J' << 8) | ('W' << 16) | ('S' << 24);
constexpr uint16_t kStatePayloadVersion = 1;
struct StatePayloadHeader {
uint32_t magic = kStatePayloadMagic;
uint16_t version = kStatePayloadVersion;
uint16_t nblocks = 0;
// CRC32 of the model's MJB bytes. When this changes, the browser must
// refetch /model.mjb before applying any further state.
uint32_t model_crc32 = 0;
};
static_assert(sizeof(StatePayloadHeader) == 12);
// Block tags. Readers must skip unknown tags.
enum StateBlockTag : uint32_t {
kTagPhysicsState = 1, // [i32 mjtState spec signature][mjtNum values...]
kTagRenderState = 2, // fixed-size block of kRenderStateSize bytes
kTagExtraGeoms = 3, // n x mjvGeom (n = size / sizeof(mjvGeom))
};
struct StateBlockHeader {
uint32_t tag = 0;
uint32_t size = 0;
};
static_assert(sizeof(StateBlockHeader) == 8);
// Fixed byte size of the render state block appended after physics state.
// These are plain C structs of int/float/double members whose total size is
// fixed, independent of the model and generally negligible compared to the size
// of the physics state
constexpr size_t kRenderStateSize =
sizeof(mjvCamera) + sizeof(mjvPerturb) + sizeof(mjvOption) +
sizeof(mjOption) + sizeof(mjVisual) + sizeof(mjStatistic) + mjNRNDFLAG;
// Maximum number of extra geoms serialized per frame. Bounds the shared
// memory buffer the StateServer allocates; WebViewer truncates longer lists.
constexpr uint32_t kMaxExtraGeoms = 1024;
// Upper bound of a serialized payload, used to size the StateServer's shared
// memory buffer. `physics_bytes` is mj_stateSize(...) * sizeof(mjtNum).
size_t MaxStatePayloadSize(size_t physics_bytes);
// Serialize the complete state payload sent over the state WebSocket.
std::vector<std::byte> SerializeStatePayload(
uint32_t model_crc32, int32_t physics_spec, const void* physics,
size_t physics_bytes, const mjvCamera& camera, const mjvPerturb& perturb,
const mjvOption& vis_options, const mjOption& opt, const mjVisual& vis,
const mjStatistic& stat, const std::vector<uint8_t>& render_flags,
const mjvGeom* extra_geoms, size_t extra_geom_count);
// Parsed view into a serialized payload. Pointers alias the input buffer and
// are NOT guaranteed to be aligned; so you must memcpy the data out before use.
struct StatePayloadView {
uint32_t model_crc32 = 0;
int32_t physics_spec = 0;
const std::byte* physics = nullptr;
size_t physics_bytes = 0;
const std::byte* render_state = nullptr; // kRenderStateSize bytes when non-null
const std::byte* extra_geoms = nullptr; // extra_geom_count * sizeof(mjvGeom)
size_t extra_geom_count = 0;
};
// Parses a payload produced by SerializeStatePayload. Returns false if the
// buffer is malformed (bad magic/version or out-of-bounds block). Blocks
// with unknown tags are skipped.
bool ParseStatePayload(const void* data, size_t size, StatePayloadView* out);
} // namespace mujoco::studio
#endif // MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_STATE_PAYLOAD_H_
@@ -0,0 +1,72 @@
// Copyright 2026 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Python bindings for the state payload wire format (state_payload.h).
//
// WebViewer serializes the /state WebSocket payload with this module each
// frame; the browser parses it with the same header (web_client_session).
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include <mujoco/mujoco.h>
#include "state_payload.h"
#include "structs.h"
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
namespace py = pybind11;
// Serialize the complete state WebSocket payload (see state_payload.h):
// physics state, render state and extra geoms as tagged blocks.
static py::bytes SerializeStatePayload(
uint32_t model_crc32, int physics_spec, const py::bytes& physics_state,
const mujoco::python::MjvCameraWrapper& camera,
const mujoco::python::MjvPerturbWrapper& perturb,
const mujoco::python::MjvOptionWrapper& vis_options,
const mujoco::python::MjModelWrapper& model,
const std::vector<uint8_t>& render_flags,
const std::vector<mujoco::python::MjvGeomWrapper>& extra_geoms) {
std::vector<mjvGeom> geoms;
geoms.reserve(extra_geoms.size());
for (const mujoco::python::MjvGeomWrapper& geom_wrapper : extra_geoms) {
if (geom_wrapper.get()) {
geoms.push_back(*geom_wrapper.get());
}
}
std::string physics = physics_state;
const std::vector<std::byte> buffer = mujoco::studio::SerializeStatePayload(
model_crc32, physics_spec, physics.data(), physics.size(), *camera.get(),
*perturb.get(), *vis_options.get(), model.get()->opt, model.get()->vis,
model.get()->stat, render_flags, geoms.data(), geoms.size());
return py::bytes(reinterpret_cast<const char*>(buffer.data()), buffer.size());
}
// Upper bound of a serialized payload for a model whose physics state is
// `physics_bytes` long. Used to size the StateServer's shared memory.
static size_t MaxStatePayloadSize(size_t physics_bytes) {
return mujoco::studio::MaxStatePayloadSize(physics_bytes);
}
PYBIND11_MODULE(state_payload, m, pybind11::mod_gil_not_used()) {
py::module_::import("mujoco._structs");
m.doc() = "MuJoCo web viewer state payload serialization";
m.def("serialize_state_payload", &SerializeStatePayload);
m.def("max_state_payload_size", &MaxStatePayloadSize);
m.attr("MAX_EXTRA_GEOMS") = mujoco::studio::kMaxExtraGeoms;
}