MuJoCo Web Viewer: add web client containing code that runs in the browser

PiperOrigin-RevId: 956698568
Change-Id: Ia4bebcb25b488d255994018da03e9115187b890c
This commit is contained in:
Matija Kecman
2026-07-30 13:16:41 -07:00
committed by Copybara-Service
parent 8b78378868
commit 4dd70d2367
11 changed files with 3043 additions and 9 deletions
@@ -0,0 +1,207 @@
<!doctype html>
<!--
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.
-->
<html>
<head>
<meta charset="utf-8" />
<title>MuJoCo Web Viewer</title>
<link rel="icon" type="image/png" href="favicon.png" />
<style>
body {
margin: 0;
overflow: hidden;
background: #1a1a2e;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
}
</style>
</head>
<body>
<canvas id="canvas" oncontextmenu="event.preventDefault()"></canvas>
<script>
// Standard WebSocket close codes.
const WS_CLOSE_POLICY_VIOLATION = 1008;
const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
// Our custom WebSocket close codes in range 4xxx.
const WS_CLOSE_CONTROLLER_TAKEN = 4001;
const WS_CLOSE_SESSION_FULL = 4002;
const WS_CLOSE_INACTIVE = 4003;
const WS_CLOSE_NOT_CONTROLLER = 4004;
var Module = {
canvas: (() => {
const canvas = document.getElementById("canvas");
return canvas;
})(),
locateFile: function (path, prefix) {
if (path.endsWith(".data")) {
return "web_client.data";
}
if (path.includes("assets/")) {
const filename = path.substring(path.lastIndexOf("/") + 1);
return "assets/" + filename;
}
return prefix + path;
},
onRuntimeInitialized: () => {
const assetsToPrefetch = [
"AtkinsonHyperlegibleNext[wght].ttf",
"AtkinsonHyperlegibleMono-Regular.ttf",
"fontawesome-webfont.ttf",
"ibl.ktx",
"pbr.filamat",
"pbr_transparent.filamat",
"pbr_packed.filamat",
"pbr_packed_transparent.filamat",
"phong_2d.filamat",
"phong_2d_fade.filamat",
"phong_2d_reflect.filamat",
"phong_2d_uv.filamat",
"phong_2d_uv_fade.filamat",
"phong_2d_uv_reflect.filamat",
"phong_color.filamat",
"phong_color_fade.filamat",
"phong_color_reflect.filamat",
"phong_cube.filamat",
"phong_cube_fade.filamat",
"phong_cube_reflect.filamat",
"outline_composite.filamat",
"outline_flatten.filamat",
"outline_jumpflood.filamat",
"decor.filamat",
"unlit_depth.filamat",
"unlit_segmentation.filamat",
"unlit_ui.filamat",
];
const assetPromises = assetsToPrefetch.map(async (filename) => {
try {
const response = await fetch("assets/" + filename);
if (!response.ok) {
console.error(`Failed to fetch asset ${filename}: ${response.statusText}`);
return;
}
const buffer = await response.arrayBuffer();
Module.registerAsset(filename, new Uint8Array(buffer));
} catch (error) {
console.error(`Error prefetching asset ${filename}:`, error);
}
});
Promise.all(assetPromises).then(() => {
Module.startApp();
});
},
};
// Set only the CSS size; SDL owns canvas.width/height. Use whole
// pixels: a fractional size (100vw/100vh under display scaling) makes
// the streamed UI relayout — and visibly flicker — every frame.
function fitCanvas() {
Module.canvas.style.width = Math.floor(window.innerWidth) + "px";
Module.canvas.style.height = Math.floor(window.innerHeight) + "px";
}
fitCanvas();
window.addEventListener("resize", fitCanvas);
// Drag & drop: upload dropped model files to the viewer over the
// /drop WebSocket. Each file is one binary frame ([u32 path length]
// [relative path utf-8][file bytes], little-endian), an empty frame
// marks the end of the drop, and the server closes once it has
// everything (parsed by web_server.py's drop_handler). Folders are
// walked recursively so models with separate asset files work; the
// viewer picks the root model file (see web_viewer._pick_drop_root).
// Loading a model changes the session for everyone, so only the
// controller may drop; the server rejects drops from other pages and the
// spectator check below just skips the pointless upload.
window.addEventListener("dragover", (e) => e.preventDefault());
window.addEventListener("drop", async (e) => {
e.preventDefault();
if (window.Module && Module.isSpectator) {
console.warn("Model drop ignored: only the controlling page can load models.");
return;
}
// Capture entries synchronously: DataTransferItems are invalidated
// as soon as this handler awaits.
const items = e.dataTransfer ? [...e.dataTransfer.items] : [];
const entries = items
.map((i) => i.webkitGetAsEntry && i.webkitGetAsEntry())
.filter(Boolean);
const files = []; // {path, file}
async function walk(entry, prefix) {
if (entry.isFile) {
const file = await new Promise((ok, err) => entry.file(ok, err));
files.push({ path: prefix + entry.name, file });
} else if (entry.isDirectory) {
const reader = entry.createReader();
for (;;) {
// readEntries returns batches (<=100); loop until empty.
const batch = await new Promise((ok, err) => reader.readEntries(ok, err));
if (!batch.length) break;
for (const child of batch) {
await walk(child, prefix + entry.name + "/");
}
}
}
}
for (const entry of entries) await walk(entry, "");
if (!files.length) return;
// The server limits each WebSocket message size. If any file exceeds the limit, the server
// closes the connection with WS_CLOSE_MESSAGE_TOO_BIG mid-upload and the entire drop is
// discarded. Check client-side first to give a clear message, not a silent failure.
const MAX_DROP_FILE_BYTES = 64 * 1024 * 1024;
const tooBig = files.find((f) => f.file.size > MAX_DROP_FILE_BYTES);
if (tooBig) {
console.warn(
`Model drop rejected: "${tooBig.path}" is larger than ` +
`${MAX_DROP_FILE_BYTES / (1024 * 1024)} MiB.`,
);
return;
}
const frames = [];
for (const { path, file } of files) {
const bytes = new Uint8Array(await file.arrayBuffer());
const name = new TextEncoder().encode(path);
const frame = new Uint8Array(4 + name.length + bytes.length);
new DataView(frame.buffer).setUint32(0, name.length, true);
frame.set(name, 4);
frame.set(bytes, 4 + name.length);
frames.push(frame);
}
const proto = location.protocol === "https:" ? "wss://" : "ws://";
// The session id tells the server which page is dropping; it only
// accepts drops from the controller.
const sid = (window.Module && Module.sessionId) || "";
const ws = new WebSocket(proto + location.host + "/drop?sid=" + encodeURIComponent(sid));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
for (const frame of frames) ws.send(frame);
ws.send(new Uint8Array(0)); // End marker; the server closes.
};
ws.onclose = (ev) => {
if (ev.code === WS_CLOSE_NOT_CONTROLLER) {
console.warn("Model drop rejected: only the controlling page can load models.");
} else if (ev.code === WS_CLOSE_MESSAGE_TOO_BIG) {
console.warn("Model drop rejected: a file exceeded the server's size limit.");
}
};
});
</script>
<script async src="web_client.js"></script>
</body>
</html>
@@ -46,6 +46,7 @@ void AppendStateBlock(std::vector<std::byte>& buffer, uint32_t tag,
}
// Serializes the render state (exactly kRenderStateSize bytes) into `ptr`.
// Must copy the same fields in the same order as ParseRenderState.
void SerializeRenderStateInto(std::byte* ptr, const mjvCamera& camera,
const mjvPerturb& perturb,
const mjvOption& vis_options, const mjOption& opt,
@@ -78,6 +79,32 @@ void SerializeRenderStateInto(std::byte* ptr, const mjvCamera& camera,
} // namespace
// Parses a render state block produced by SerializeStatePayload.
// Must copy the same fields in the same order as SerializeRenderStateInto.
void ParseRenderState(const std::byte* data, RenderStateView* out) {
const std::byte* ptr = data;
memcpy(&out->camera, ptr, sizeof(mjvCamera));
ptr += sizeof(mjvCamera);
memcpy(&out->perturb, ptr, sizeof(mjvPerturb));
ptr += sizeof(mjvPerturb);
memcpy(&out->vis_options, ptr, sizeof(mjvOption));
ptr += sizeof(mjvOption);
memcpy(&out->opt, ptr, sizeof(mjOption));
ptr += sizeof(mjOption);
memcpy(&out->vis, ptr, sizeof(mjVisual));
ptr += sizeof(mjVisual);
memcpy(&out->stat, ptr, sizeof(mjStatistic));
ptr += sizeof(mjStatistic);
memcpy(out->render_flags, ptr, mjNRNDFLAG);
}
size_t MaxStatePayloadSize(size_t physics_bytes) {
return sizeof(StatePayloadHeader) + 3 * sizeof(StateBlockHeader) +
(sizeof(int32_t) + physics_bytes) + kRenderStateSize +
@@ -123,6 +123,21 @@ struct StatePayloadView {
// with unknown tags are skipped.
bool ParseStatePayload(const void* data, size_t size, StatePayloadView* out);
// A decoded render state block.
struct RenderStateView {
mjvCamera camera;
mjvPerturb perturb;
mjvOption vis_options;
mjOption opt;
mjVisual vis;
mjStatistic stat;
uint8_t render_flags[mjNRNDFLAG];
};
// Decodes a render state block produced by the serializer. `data` must hold
// kRenderStateSize bytes (e.g. StatePayloadView::render_state).
void ParseRenderState(const std::byte* data, RenderStateView* out);
} // namespace mujoco::studio
#endif // MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_STATE_PAYLOAD_H_
@@ -0,0 +1,745 @@
// 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 implements the Web Viewer Browser Client. NetImgui is used to
// transmit remote ImGui draw data in a browser and the filament renderer is
// used to render the UI and the 3D scene.
#include <emscripten.h>
#include <emscripten/bind.h>
#include <emscripten/fetch.h>
#include <stdint.h>
#include <stdio.h>
#include <algorithm>
#include <cfloat>
#include <cinttypes>
#include <cmath>
#include <cstddef>
#include <cstring>
#include <fstream>
#include <initializer_list>
#include <memory>
#include <span>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include "third_party/SDL2/include/SDL.h"
#include "third_party/SDL2/include/SDL_opengl.h"
#include <imgui.h>
#include <implot.h>
#include <mujoco/mujoco.h>
#include "experimental/platform/hal/renderer.h"
#include "experimental/platform/hal/window.h"
#include "experimental/platform/sim/model_holder.h"
#include "experimental/platform/ux/interaction.h"
#include <NetImgui_Api.h>
#include "google/logging.h"
#include "state_payload.h"
#include "web_client_local_ui.h"
#include "web_client_remote_ui.h"
#include "web_client_session.h"
#if !defined(__EMSCRIPTEN__)
#error "web_client.cc is only supported for Emscripten builds"
#endif
// No-op wrapper for glGetError(), installed via -Wl,--wrap=glGetError.
// Filament's GL backend calls glGetError() hundreds of times per frame
// through GLUtils::checkGLError/assertGLError. In WebGL each call forces a
// synchronous GPU pipeline flush across the JS-WASM bridge (~0.2ms each).
// This stub eliminates that cost entirely.
extern "C" GLenum __wrap_glGetError(void) { return GL_NO_ERROR; }
using mujoco::studio::DisconnectNotice;
using mujoco::studio::RemoteUi;
using mujoco::studio::RemoteUiState;
using mujoco::studio::RoleWindow;
using mujoco::studio::Session;
using mujoco::studio::SessionRole;
using mujoco::studio::SessionView;
using mujoco::studio::StatePayloadView;
// How a spectating page drives its camera. The free modes control the local
// camera directly; kSpecCamFollow mirrors the controller's camera from the
// state broadcast.
enum SpectatorCamMode {
kSpecCamTumble = 0, // Orbit around the lookat point with the mouse.
kSpecCamWasd, // Fly camera: WASD/QE moves, mouse drag turns.
kSpecCamFollow, // Follow the controller's camera.
};
// Byte-rate telemetry shown in the role window.
struct Telemetry {
double last_rate_time = 0;
uint64_t gui_bytes_per_sec = 0;
uint64_t sim_bytes_per_sec = 0;
};
// The implementation of every interface needed by the session and remote UI.
class AppCallbacks final : public RemoteUi::Callbacks,
public Session::Callbacks {
public:
// RemoteUi::Callbacks
uintptr_t UploadTexture(uintptr_t current, const std::byte* rgba,
uint32_t width, uint32_t height) override;
bool GpuReady() override;
// Session::Callbacks
bool ReadyForPayload() override;
void OnPayload(const StatePayloadView& view) override;
void ConnectRemoteUi() override;
void ShutdownRemoteUi() override;
void SetCameraMode(int mode) override;
};
struct App {
std::unique_ptr<mujoco::platform::Window> window;
std::unique_ptr<mujoco::platform::ModelHolder> model_holder;
mujoco::platform::Renderer* renderer = nullptr;
mjvPerturb perturb;
mjvCamera camera;
mjvOption vis_options;
// Spectator camera
int spectator_cam_mode = kSpecCamTumble;
float spectator_cam_speed = 0.001f; // WASD speed; accelerates while held.
Telemetry telemetry;
// Main loop frame counters (MainLoopImpl): reconnect pacing for the state
// WebSocket. (The session paces its own /ui claims by time.)
int frame_count = 0;
int last_state_retry_frame = 0;
// Backend state received from the Python simulation via WebSocket.
std::vector<mjtNum> backend_state;
int backend_state_sig = 0;
bool backend_state_dirty = false;
// User-injected geoms received with the state payload.
std::vector<mjvGeom> extra_geoms;
AppCallbacks callbacks;
RemoteUi remote_ui{callbacks};
Session session{callbacks};
DisconnectNotice disconnect_notice;
RoleWindow role_window;
};
App g_app;
// WebSocket base URL matching the page origin, e.g. "ws://host:8080" or
// "wss://tunnel.example.com" behind an HTTPS tunnel. All viewer WebSockets
// are served as paths (/ui, /state) on the same host and port as the page
// itself, so exposing or tunneling that single port exposes the whole
// viewer.
std::string GetWsBaseUrl() {
char* base = emscripten_run_script_string(
"(window.location.protocol === 'https:' ? 'wss://' : 'ws://') + "
"window.location.host");
std::string url = base != nullptr ? base : "";
if (url == "ws://" || url == "wss://" || url.empty()) {
// No host in the page URL (e.g. file://) — assume a local server.
return "ws://localhost:8080";
}
return url;
}
// Stable per-tab id sent with every WebSocket connect (?sid=...), letting the
// server tie this page's /ui and /state connections together. It lives in
// sessionStorage so it survives page reloads: on a model change every page
// reloads and the server restarts with the controller slot reserved for the
// previous controller's sid, a reload-stable id is what lets the controller
// keep control.
std::string GetSessionId() {
static const std::string sid = emscripten_run_script_string(
"(function() {"
" Module.session_id = Module.session_id ||"
" sessionStorage.getItem('mjwv_sid') ||"
" (Date.now().toString(36) + Math.random().toString(36).slice(2));"
" sessionStorage.setItem('mjwv_sid', Module.session_id);"
" return Module.session_id;"
"})()");
return sid;
}
std::string WsUrl(const char* path) {
return GetWsBaseUrl() + path + "?sid=" + GetSessionId();
}
// Returns true if the Filament rendering context is initialized and ready for
// GPU texture uploads. The Renderer object is created in main() and is always
// non-null, but the Filament context is only initialized when Renderer::Init()
// is called from SetupScene after the async model fetch completes.
bool IsFilamentReady() {
return g_app.renderer && g_app.model_holder && g_app.model_holder->ok();
}
// Applies a parsed state payload to the app. Called via AppCallbacks::OnPayload
// after Session validates model CRC and payload readiness.
void ApplyStatePayload(const StatePayloadView& view) {
mjModel* model = g_app.model_holder->model();
// Physics state. Guard against a size mismatch (e.g. a stale packet from
// before a model change).
if (view.physics != nullptr) {
const size_t expected_bytes =
mj_stateSize(model, view.physics_spec) * sizeof(mjtNum);
if (view.physics_bytes == expected_bytes) {
g_app.backend_state.resize(view.physics_bytes / sizeof(mjtNum));
memcpy(g_app.backend_state.data(), view.physics, view.physics_bytes);
g_app.backend_state_sig = view.physics_spec;
g_app.backend_state_dirty = true;
} else {
LOG(Warning, "Physics state size mismatch (%zu != %zu); dropping",
view.physics_bytes, expected_bytes);
}
}
// Render state. The headless viewer owns the camera and perturbation state
// (all input is forwarded to it and handled by the same code as the native
// viewer); the browser just renders them.
if (view.render_state != nullptr) {
mujoco::studio::RenderStateView rs;
mujoco::studio::ParseRenderState(view.render_state, &rs);
// Spectators in a free camera mode keep their own local camera; everyone
// else (the controller, and spectators in Follow Controller) mirrors the
// controller's camera.
if (g_app.session.Role() != SessionRole::kSpectating ||
g_app.spectator_cam_mode == kSpecCamFollow) {
g_app.camera = rs.camera;
}
g_app.perturb = rs.perturb;
g_app.vis_options = rs.vis_options;
model->opt = rs.opt;
model->vis = rs.vis;
model->stat = rs.stat;
// Apply render flags to the renderer's scene if available.
if (g_app.renderer) {
memcpy(g_app.renderer->GetRenderFlags(), rs.render_flags, mjNRNDFLAG);
}
}
// Extra geoms. memcpy since the payload data is not guaranteed to be aligned.
g_app.extra_geoms.resize(view.extra_geom_count);
if (view.extra_geom_count > 0) {
memcpy(g_app.extra_geoms.data(), view.extra_geoms,
view.extra_geom_count * sizeof(mjvGeom));
}
}
void SetSpectatorCameraMode(int mode) {
if (mode == g_app.spectator_cam_mode) {
return;
}
g_app.spectator_cam_mode = mode;
if (!g_app.model_holder || !g_app.model_holder->ok()) {
return;
}
const mjModel* model = g_app.model_holder->model();
if (mode == kSpecCamTumble) {
mujoco::platform::SetCamera(model, &g_app.camera,
mujoco::platform::kTumbleCameraIdx);
} else if (mode == kSpecCamWasd) {
mujoco::platform::SetCamera(model, &g_app.camera,
mujoco::platform::kFreeCameraIdx);
}
// kSpecCamFollow: the next state payload restores the controller's camera.
}
// Local camera control for a spectating page in a free camera mode. The
// controller's input goes to the headless viewer instead (CaptureAndSendInput),
// which streams its camera back over the state WebSocket.
// TODO(matijak): Share the camera handling code with the studio app (e.g. in
// platform/ux/interaction.cc) instead of duplicating it here.
void HandleSpectatorCameraInput() {
if (g_app.spectator_cam_mode == kSpecCamFollow) {
return;
}
if (!g_app.model_holder || !g_app.model_holder->ok()) {
return;
}
const mjModel* model = g_app.model_holder->model();
ImGuiIO& io = ImGui::GetIO();
const bool wasd = g_app.spectator_cam_mode == kSpecCamWasd;
// The camera can be fixed on entry (SetupScene honours the model's
// vis.global.cameraid, and Follow Controller mirrors whatever camera the
// controller uses), and mjv_moveCamera ignores fixed cameras. Coerce to a
// free camera so the free modes always respond to input.
if (g_app.camera.type == mjCAMERA_FIXED) {
mjv_defaultFreeCamera(model, &g_app.camera);
if (wasd) {
mujoco::platform::SetCamera(model, &g_app.camera,
mujoco::platform::kFreeCameraIdx);
}
}
if (!io.WantCaptureMouse && io.DisplaySize.x > 0 && io.DisplaySize.y > 0) {
const float mouse_dx = io.MouseDelta.x / io.DisplaySize.x;
const float mouse_dy = io.MouseDelta.y / io.DisplaySize.y;
const bool is_mouse_dragging =
(mouse_dx != 0.0f || mouse_dy != 0.0f) &&
(ImGui::IsMouseDown(ImGuiMouseButton_Left) ||
ImGui::IsMouseDown(ImGuiMouseButton_Right) ||
ImGui::IsMouseDown(ImGuiMouseButton_Middle));
if (is_mouse_dragging) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
if (wasd) {
mjv_moveCamera(model, mjMOUSE_TURN_H, mouse_dx, 0.f, &g_app.camera);
mjv_moveCamera(model, mjMOUSE_TURN_V, 0.f, mouse_dy, &g_app.camera);
} else {
mjv_moveCamera(model, mjMOUSE_ROTATE_H, mouse_dx, 0.f, &g_app.camera);
mjv_moveCamera(model, mjMOUSE_ROTATE_V, 0.f, mouse_dy, &g_app.camera);
}
} else if (ImGui::IsMouseDown(ImGuiMouseButton_Middle) && !wasd) {
mjv_moveCamera(model, mjMOUSE_ZOOM, 0.f, mouse_dy, &g_app.camera);
}
if (ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
mjv_moveCamera(model, io.KeyShift ? mjMOUSE_MOVE_H : mjMOUSE_MOVE_V,
mouse_dx, mouse_dy, &g_app.camera);
}
}
// Mouse scroll zooms towards/away from the lookat point; ignored by the
// user-centered WASD camera which has no lookat point.
const float mouse_scroll = io.MouseWheel / 50.0f;
if (mouse_scroll != 0.0f && !wasd) {
mjv_moveCamera(model, mjMOUSE_ZOOM, 0.f, -mouse_scroll, &g_app.camera);
}
}
// WASD/QE flying, with the same accelerating speed as the studio app.
if (wasd && !io.WantCaptureKeyboard) {
bool moved = false;
const float speed = g_app.spectator_cam_speed;
if (ImGui::IsKeyDown(ImGuiKey_W)) {
mjv_moveCamera(model, mjMOUSE_MOVE_H_REL, 0, speed, &g_app.camera);
moved = true;
} else if (ImGui::IsKeyDown(ImGuiKey_S)) {
mjv_moveCamera(model, mjMOUSE_MOVE_H_REL, 0, -speed, &g_app.camera);
moved = true;
}
if (ImGui::IsKeyDown(ImGuiKey_A)) {
mjv_moveCamera(model, mjMOUSE_MOVE_H_REL, -speed, 0, &g_app.camera);
moved = true;
} else if (ImGui::IsKeyDown(ImGuiKey_D)) {
mjv_moveCamera(model, mjMOUSE_MOVE_H_REL, speed, 0, &g_app.camera);
moved = true;
}
if (ImGui::IsKeyDown(ImGuiKey_Q)) {
mjv_moveCamera(model, mjMOUSE_MOVE_V_REL, 0, speed, &g_app.camera);
moved = true;
} else if (ImGui::IsKeyDown(ImGuiKey_E)) {
mjv_moveCamera(model, mjMOUSE_MOVE_V_REL, 0, -speed, &g_app.camera);
moved = true;
}
if (moved) {
const float max_speed = io.KeyShift ? 0.1f : 0.01f;
g_app.spectator_cam_speed =
std::min(g_app.spectator_cam_speed + 0.001f, max_speed);
} else {
g_app.spectator_cam_speed = 0.001f;
}
}
}
// --- AppCallbacks method bodies (see the class declaration above App). ---
uintptr_t AppCallbacks::UploadTexture(uintptr_t current, const std::byte* rgba,
uint32_t width, uint32_t height) {
return g_app.renderer->UploadImage(current, rgba, width, height,
rgba ? 4 : 0);
}
bool AppCallbacks::GpuReady() { return IsFilamentReady(); }
bool AppCallbacks::ReadyForPayload() {
return g_app.model_holder && g_app.model_holder->ok();
}
void AppCallbacks::OnPayload(const StatePayloadView& view) {
ApplyStatePayload(view);
}
void AppCallbacks::ConnectRemoteUi() { g_app.remote_ui.Connect(WsUrl("/ui")); }
void AppCallbacks::ShutdownRemoteUi() { g_app.remote_ui.Shutdown(); }
void AppCallbacks::SetCameraMode(int mode) { SetSpectatorCameraMode(mode); }
void BuildBrowserGui() {
// Refresh the byte-rate telemetry the role window shows.
const double now = ImGui::GetTime();
if (now - g_app.telemetry.last_rate_time >= 1.0) {
g_app.telemetry.gui_bytes_per_sec = g_app.remote_ui.ConsumeByteCount();
g_app.telemetry.sim_bytes_per_sec = g_app.session.ConsumeByteCount();
g_app.telemetry.last_rate_time = now;
}
const double last_msg = g_app.session.LastMessageTime();
const double stale_sec =
last_msg > 0 ? emscripten_get_now() / 1000.0 - last_msg : -1.0;
g_app.disconnect_notice.Draw(g_app.session.ServerCloseCode(), stale_sec,
g_app.session.ReloadPending());
SessionView view;
g_app.session.FillView(&view);
view.gui_bytes_per_sec = g_app.telemetry.gui_bytes_per_sec;
view.sim_bytes_per_sec = g_app.telemetry.sim_bytes_per_sec;
view.have_remote_frame = g_app.remote_ui.RemoteDrawData() != nullptr;
view.camera_mode = g_app.spectator_cam_mode;
// The session is the SessionActions implementation: the role window's
// intents land there directly.
g_app.role_window.Draw(view, g_app.session);
}
//=================================================================================================
// Main loop — called once per frame.
//=================================================================================================
void MainLoopImpl();
void MainLoop() {
// An exception escaping the requestAnimationFrame callback kills the main
// loop silently causing the canvas to freeze on the last rendered frame and
// input capture to stop with only an opaque "Uncaught <ptr>" in the console.
// Here we catch, log, and stop explicitly instead.
try {
MainLoopImpl();
} catch (const std::exception& e) {
LOG(Error, "FATAL: uncaught exception in MainLoop: %s", e.what());
emscripten_cancel_main_loop();
} catch (...) {
LOG(Error, "FATAL: uncaught non-std exception in MainLoop");
emscripten_cancel_main_loop();
}
}
void MainLoopImpl() {
g_app.frame_count++;
// Session upkeep (the liveness heartbeat).
g_app.session.Update();
// Reconnect the state WebSocket if it dropped. Receiving a payload with a
// different model identity then triggers a page reload (Session). Deliberate
// server closes (session full, inactivity) are transient; retry them too,
// using a gentler pace.
const int state_retry_interval =
g_app.session.ServerCloseCode() != 0 ? 300 : 60;
if (!g_app.session.HasSocket() && !g_app.session.ReloadPending() &&
g_app.model_holder && g_app.model_holder->ok() &&
g_app.frame_count - g_app.last_state_retry_frame > state_retry_interval) {
g_app.last_state_retry_frame = g_app.frame_count;
LOG(Info, "State WebSocket down; reconnecting...");
g_app.session.Connect(WsUrl("/state"));
}
// Pass the remote UI (/ui) WebSocket state to the session state machine.
// The session uses this to manage attempts to claim the single controller
// slot, handle retry pacing if a claim is rejected or dropped, and drive role
// transitions (Controlling vs Spectating).
RemoteUiState ui_state = RemoteUiState::kNoSocket;
if (g_app.remote_ui.HasSocket()) {
switch (g_app.remote_ui.ConnectionState()) {
case RemoteUi::ReadyState::kOpen:
ui_state = RemoteUiState::kOpen;
break;
case RemoteUi::ReadyState::kClosed:
case RemoteUi::ReadyState::kError:
ui_state = RemoteUiState::kClosedOrError;
break;
default:
ui_state = RemoteUiState::kConnecting;
break;
}
}
g_app.session.HandleRemoteUiState(ui_state, g_app.remote_ui.CloseCode());
// Process incoming UI data BEFORE the ImGui frame: textures and draw
// frames must be ready before we start the local ImGui frame and render.
if (g_app.window) {
g_app.remote_ui.SetMaxClip(static_cast<float>(g_app.window->GetWidth()),
static_cast<float>(g_app.window->GetHeight()));
}
g_app.remote_ui.ReceiveAndProcessCommands(g_app.frame_count);
// Event loop and ImGui NewFrame via window abstraction.
mujoco::platform::Window::Status status = g_app.window->NewFrame();
if (status == mujoco::platform::Window::kQuitting) {
// NewFrame() started an ImGui frame; end it before bailing out.
ImGui::EndFrame();
emscripten_cancel_main_loop();
return;
}
// For the controller, all scene interaction (camera orbit/zoom, perturbation,
// picking) is handled by the headless viewer: CaptureAndSendInput()
// forwards this frame's input over NetImgui, and the resulting camera/perturb
// state streams back over the state WebSocket (see ApplyStatePayload).
// Spectators have no input channel; in a free camera mode they drive their
// local camera directly.
g_app.remote_ui.CaptureAndSendInput();
if (g_app.session.Role() == SessionRole::kSpectating) {
HandleSpectatorCameraInput();
}
BuildBrowserGui();
// Finalize the local ImGui draw data. ImguiBridge::Update() (called inside
// Filament's Render) will call ImGui::Render() again, but that is a no-op
// once the frame has already been rendered.
ImGui::Render();
// Inject remote draw lists into the local ImDrawData so that Filament's
// ImguiBridge renders them alongside the local UI. Remote lists are inserted
// first (background) and local lists are re-added after (foreground), so the
// local UI always renders on top of remote content. Note that
// ImGui::GetDrawData() is only valid after ImGui::Render() and until the next
// call to ImGui::NewFrame().
ImDrawData* remote_draw_data = g_app.remote_ui.RemoteDrawData();
if (remote_draw_data && remote_draw_data->Valid &&
g_app.session.Role() == SessionRole::kControlling) {
ImDrawData* local_draw_data = ImGui::GetDrawData();
if (local_draw_data) {
// Save local draw lists.
ImVector<ImDrawList*> local_lists;
local_lists.reserve(local_draw_data->CmdListsCount);
for (int i = 0; i < local_draw_data->CmdListsCount; ++i) {
local_lists.push_back(local_draw_data->CmdLists[i]);
}
// Clear and rebuild: remote first, then local.
local_draw_data->CmdLists.resize(0);
local_draw_data->CmdListsCount = 0;
local_draw_data->TotalVtxCount = 0;
local_draw_data->TotalIdxCount = 0;
// Remote draw lists (background).
for (int i = 0; i < remote_draw_data->CmdListsCount; ++i) {
local_draw_data->AddDrawList(remote_draw_data->CmdLists[i]);
}
// Local draw lists (foreground).
for (int i = 0; i < local_lists.Size; ++i) {
local_draw_data->AddDrawList(local_lists[i]);
}
}
}
// Render the scene and all ImGui UI (local and remote).
if (g_app.renderer && g_app.model_holder && g_app.model_holder->ok()) {
// Apply backend state if available.
if (g_app.backend_state_dirty) {
mjModel* model = g_app.model_holder->model();
mjData* data = g_app.model_holder->data();
mj_setState(model, data, g_app.backend_state.data(),
g_app.backend_state_sig);
mj_forward(model, data);
g_app.backend_state_dirty = false;
}
int width =
static_cast<int>(g_app.window->GetWidth() * g_app.window->GetScale());
int height =
static_cast<int>(g_app.window->GetHeight() * g_app.window->GetScale());
if (width > 0 && height > 0) {
g_app.renderer->Render(g_app.model_holder->model(),
g_app.model_holder->data(), &g_app.perturb,
&g_app.camera, &g_app.vis_options, width, height,
/*pixels=*/{}, std::span(g_app.extra_geoms));
}
}
g_app.window->Present();
}
void SetupScene(const mjModel* m) {
g_app.renderer->Init(m);
// Upload any textures (e.g. the font atlas) that were buffered before the
// Filament context was available.
g_app.remote_ui.FlushPendingTextures();
mjv_defaultPerturb(&g_app.perturb);
mjv_defaultCamera(&g_app.camera);
mjv_defaultOption(&g_app.vis_options);
const int model_cam = m->vis.global.cameraid;
if (model_cam >= 0 && model_cam < m->ncam) {
mujoco::platform::SetCamera(m, &g_app.camera, model_cam);
} else {
mjv_defaultFreeCamera(m, &g_app.camera);
}
}
// Standard CRC-32 (ISO-HDLC, poly 0xEDB88320), matching Python's zlib.crc32
// so the fetched model's checksum can be compared against the crc the Python
// side stamps into each state payload.
uint32_t Crc32(const uint8_t* data, size_t len) {
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
for (int bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ (0xEDB88320u & (~(crc & 1u) + 1u));
}
}
return crc ^ 0xFFFFFFFFu;
}
void OnFetchSuccess(emscripten_fetch_t* fetch) {
LOG(Info, "Fetched model.mjb, size: %llu", fetch->numBytes);
g_app.model_holder = mujoco::platform::ModelHolder::FromBuffer(
std::span<const std::byte>(
reinterpret_cast<const std::byte*>(fetch->data),
static_cast<size_t>(fetch->numBytes)),
"application/mjb", "model.mjb");
if (g_app.model_holder && g_app.model_holder->ok()) {
LOG(Info, "Model loaded successfully!");
mj_forward(g_app.model_holder->model(), g_app.model_holder->data());
if (g_app.window) {
SetupScene(g_app.model_holder->model());
}
// Baseline the state link on the model we just fetched, so a model swap
// that raced this fetch is caught by the very first payload.
g_app.session.SetModelCrc32(
Crc32(reinterpret_cast<const uint8_t*>(fetch->data),
static_cast<size_t>(fetch->numBytes)));
// Connect the state WebSocket to receive simulation state from Python.
g_app.session.Connect(WsUrl("/state"));
} else {
LOG(Error, "Failed to load model: %s",
g_app.model_holder ? g_app.model_holder->error().data()
: "Unknown error");
}
emscripten_fetch_close(fetch);
}
void StartModelFetch();
void OnFetchError(emscripten_fetch_t* fetch) {
LOG(Error, "Failed to fetch model.mjb, status: %d; retrying", fetch->status);
emscripten_fetch_close(fetch);
// The most likely cause is the Python side restarting its server after a
// model change, so retry rather than leaving the page permanently blank.
emscripten_async_call([](void*) { StartModelFetch(); }, nullptr, 1000);
}
void StartModelFetch() {
emscripten_fetch_attr_t attr;
emscripten_fetch_attr_init(&attr);
strcpy(attr.requestMethod, "GET");
attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY;
attr.onsuccess = OnFetchSuccess;
attr.onerror = OnFetchError;
emscripten_fetch(&attr, "/model.mjb");
}
// Holds assets (Filament assets and local ImGui fonts) that the page fetches
// and pushes in via the registerAsset() binding before startApp() runs, so the
// resource providers below can resolve them without a filesystem.
class AssetRegistry {
public:
static AssetRegistry& Instance() {
static AssetRegistry instance;
return instance;
}
void RegisterAsset(std::string filename, std::string contents) {
assets_[std::move(filename)] = std::move(contents);
}
const std::string& Get(std::string_view filename) const {
filename = filename.substr(filename.find_first_of(':') + 1);
static std::string empty;
auto it = assets_.find(std::string(filename));
return it != assets_.end() ? it->second : empty;
}
private:
std::unordered_map<std::string, std::string> assets_;
};
// Exposed to JS (see EMSCRIPTEN_BINDINGS): the page calls this once per asset.
void RegisterAsset(std::string filename, std::string contents) {
AssetRegistry::Instance().RegisterAsset(std::move(filename),
std::move(contents));
}
// Registers resource providers so that "filament:" and "font:" asset requests
// resolve from the AssetRegistry populated by registerAsset().
static void RegisterAssetProviders() {
mjpResourceProvider resource_provider;
mjp_defaultResourceProvider(&resource_provider);
resource_provider.open = [](mjResource* resource) {
AssetRegistry& r = AssetRegistry::Instance();
return static_cast<int>(r.Get(resource->name).size());
};
resource_provider.read = [](mjResource* resource, const void** buffer) {
AssetRegistry& r = AssetRegistry::Instance();
const std::string& contents = r.Get(resource->name);
*buffer = contents.data();
return static_cast<int>(contents.size());
};
resource_provider.close = [](mjResource* resource) {};
for (const char* prefix : {"filament", "font"}) {
resource_provider.prefix = prefix;
mjp_registerResourceProvider(&resource_provider);
}
}
// Starts the viewer once the page has registered every asset. Exposed to JS
// and called from index.html after the fetches complete. Note that calling
// emscripten_set_main_loop with simulate_infinite_loop=1 never returns, so
// there is no post-loop cleanup.
void StartApp() {
mujoco::platform::Window::Config config;
config.gfx_mode = mujoco::platform::GraphicsMode::FilamentWebGl;
// Load the Studio UI fonts for the browser's local ImGui (the role window);
// the "font:" resource provider above resolves them from the AssetRegistry.
// (The streamed/remote UI carries its own font atlas separately.)
config.load_fonts = true;
g_app.window = std::make_unique<mujoco::platform::Window>("MuJoCo Web Viewer",
1400, 720, config);
ImPlot::CreateContext(); // Needed if the server app uses ImPlot.
g_app.renderer = new mujoco::platform::Renderer(
g_app.window->GetNativeWindowHandle(), config.gfx_mode);
NetImgui::Internal::Network::Startup();
SDL_StartTextInput();
// The UI stream is a path on the page's own host and port.
g_app.remote_ui.Connect(WsUrl("/ui"));
StartModelFetch();
emscripten_set_main_loop(MainLoop, 0, 1);
}
int main(int argc, char** argv) {
RegisterAssetProviders();
return 0;
}
EMSCRIPTEN_BINDINGS(web_client_bindings) {
emscripten::function("registerAsset", &RegisterAsset);
emscripten::function("startApp", &StartApp);
}
@@ -0,0 +1,387 @@
// 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.
#include "web_client_local_ui.h"
#include <algorithm>
#include <cfloat>
#include <cinttypes>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <initializer_list>
#include <imgui.h>
#include "experimental/platform/ux/imgui_widgets.h"
#include "google/logging.h"
namespace mujoco::studio {
namespace {
const ImVec2 kFullWidth(-FLT_MIN, 0.0f);
const ImVec4 kSpectatingColor(1.0f, 0.75f, 0.2f, 1.0f);
const ImVec4 kControllingColor(0.3f, 0.9f, 0.4f, 1.0f);
const ImVec4 kQueueColor(1.0f, 0.62f, 0.15f, 1.0f);
const ImVec4 kConnectingColor(0.6f, 0.6f, 0.6f, 1.0f);
// Draws one screen-centered DISCONNECTED window.
void DrawDisconnectWindow(const char* window_id,
std::initializer_list<const char*> lines) {
const ImGuiIO& io = ImGui::GetIO();
ImGui::SetNextWindowPos(
ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f),
ImGuiCond_Always, ImVec2(0.5f, 0.5f));
ImGui::Begin(window_id, nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize);
platform::CenteredBanner("DISCONNECTED", ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
for (const char* line : lines) {
platform::CenteredLine(line, nullptr);
}
ImGui::End();
}
} // namespace
void DisconnectNotice::Draw(int server_close_code,
double seconds_since_last_payload,
bool reload_pending) {
if (server_close_code != 0) {
const char* reason = "Disconnected by the viewer.";
if (server_close_code == kWsCloseSessionFull) {
reason = "Session is full: too many viewers connected.";
} else if (server_close_code == kWsCloseInactive) {
reason = "Disconnected after inactivity.";
}
DrawDisconnectWindow("##disconnected_by_server",
{reason, "Retrying; reconnects automatically."});
}
// The silence notice keys on state-stream staleness rather than socket
// state: a killed, suspended (Ctrl+Z), or unreachable server all go
// silent, but only a killed one closes its sockets. Clears itself when
// traffic resumes. Suppressed before the first payload (negative
// staleness), after a deliberate server-side close (its own notice
// above), and during model-swap reloads.
const bool link_stale =
seconds_since_last_payload > kServerSilenceNoticeSec &&
server_close_code == 0 && !reload_pending;
if (!link_stale) {
logged_ = false;
return;
}
if (!logged_) {
LOG(Info, "Showing server-not-reachable notice");
logged_ = true;
}
DrawDisconnectWindow(
"##disconnected",
{"Viewer server is not reachable: the Python script may",
"have stopped. This page reconnects automatically if the",
"viewer comes back."});
}
void RoleWindow::Draw(const SessionView& view, SessionActions& actions) {
ComputeAnchorPositions();
// While anchored, the window is re-pinned every frame (so it follows
// canvas resizes) for both the collapsed pill and the expanded window
// (same ImGui window, so one call covers whichever Begin runs this
// frame) — except while it is being dragged.
if (anchor_ != kAnchorFree && !dragging_) {
ImGui::SetNextWindowPos(anchor_pos_[anchor_ - kAnchorTopLeft],
ImGuiCond_Always,
kAnchorPivot[anchor_ - kAnchorTopLeft]);
}
// While someone waits for control, the window background pulses toward
// a dark orange (the queue size itself is shown in the expanded form).
const bool pulse =
view.role == SessionRole::kControlling && view.queue_len > 0;
if (pulse) {
const float phase =
0.5f + 0.5f * sinf(static_cast<float>(ImGui::GetTime()) * 2.5f);
ImVec4 bg = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg);
const ImVec4 orange(0.45f, 0.22f, 0.02f, bg.w);
const float blend = 0.6f * phase;
bg.x += (orange.x - bg.x) * blend;
bg.y += (orange.y - bg.y) * blend;
bg.z += (orange.z - bg.z) * blend;
ImGui::PushStyleColor(ImGuiCol_WindowBg, bg);
}
if (mode_ == Mode::kCollapsed) {
DrawCollapsed(view);
} else {
DrawExpanded(view, actions);
}
if (pulse) {
ImGui::PopStyleColor();
}
}
void RoleWindow::ComputeAnchorPositions() {
const ImVec2 canvas = ImGui::GetIO().DisplaySize;
anchor_pos_[0] = ImVec2(kEdgeMargin, kEdgeMargin);
anchor_pos_[1] = ImVec2(canvas.x * 0.5f, kEdgeMargin);
anchor_pos_[2] = ImVec2(canvas.x - kEdgeMargin, kEdgeMargin);
anchor_pos_[3] = ImVec2(kEdgeMargin, canvas.y - kEdgeMargin);
anchor_pos_[4] = ImVec2(canvas.x * 0.5f, canvas.y - kEdgeMargin);
anchor_pos_[5] = ImVec2(canvas.x - kEdgeMargin, canvas.y - kEdgeMargin);
}
// Tracks a drag of the window and snaps on release: if the window was
// dropped within max(20% of its size, 100px) of where an anchor would
// place it, adopt that anchor; otherwise it floats Free where it was
// dropped. Call between Begin and End of whichever form is visible.
void RoleWindow::UpdateSnap() {
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem)) {
dragging_ = true;
} else if (dragging_ && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
dragging_ = false;
const ImVec2 pos = ImGui::GetWindowPos();
const ImVec2 size = ImGui::GetWindowSize();
const float threshold = std::max(0.2f * std::max(size.x, size.y), 100.0f);
int best = -1;
float best_d2 = threshold * threshold;
for (int i = 0; i < 6; ++i) {
// Per-axis distance to where this anchor would place the window,
// clamped to zero on the anchor's off-screen side: a window dropped
// past an edge or corner (mostly off-screen) still snaps to that
// anchor instead of being left stranded outside the canvas.
float dx = pos.x - (anchor_pos_[i].x - kAnchorPivot[i].x * size.x);
if (kAnchorPivot[i].x == 0.0f) {
dx = std::max(dx, 0.0f); // Dropped past the left edge.
} else if (kAnchorPivot[i].x == 1.0f) {
dx = std::min(dx, 0.0f); // Dropped past the right edge.
}
float dy = pos.y - (anchor_pos_[i].y - kAnchorPivot[i].y * size.y);
if (kAnchorPivot[i].y == 0.0f) {
dy = std::max(dy, 0.0f); // Dropped past the top edge.
} else {
dy = std::min(dy, 0.0f); // Dropped past the bottom edge.
}
const float d2 = dx * dx + dy * dy;
if (d2 <= best_d2) {
best_d2 = d2;
best = i;
}
}
anchor_ =
best >= 0 ? static_cast<Anchor>(kAnchorTopLeft + best) : kAnchorFree;
}
}
// Data rates, shown to both roles (spectators receive no GUI stream, so
// that line reads 0 for them). With more than one viewer, the number in
// parentheses is the host's total outgoing sim bandwidth: every connected
// browser receives the same sim stream.
void RoleWindow::DataRateLines(const SessionView& view) {
ImGui::Text("GUI Data Rate: %" PRIu64 " KiB/s",
static_cast<uint64_t>(view.gui_bytes_per_sec / 1024));
if (view.role == SessionRole::kSpectating) {
ImGui::SetItemTooltip("The UI is not streamed to spectators.");
} else if (!view.have_remote_frame) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.0f, 0.0f, 1.0f), "(Waiting...)");
}
ImGui::Text("Sim Data Rate: %" PRIu64 " KiB/s",
static_cast<uint64_t>(view.sim_bytes_per_sec / 1024));
if (view.viewers > 1) {
ImGui::SameLine();
ImGui::Text(
"(%" PRIu64 " KiB/s)",
static_cast<uint64_t>(view.sim_bytes_per_sec * view.viewers / 1024));
ImGui::SetItemTooltip("Total sim data sent across all viewers.");
}
}
void RoleWindow::DrawCollapsed(const SessionView& view) {
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(16.0f, 16.0f));
ImGui::Begin("Role", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_AlwaysAutoResize);
if (view.role == SessionRole::kSpectating) {
ImGui::TextColored(kSpectatingColor, "SPECTATING");
} else if (view.role == SessionRole::kControlling) {
ImGui::TextColored(kControllingColor, "CONTROLLING");
} else {
ImGui::TextColored(kConnectingColor, "CONNECTING");
}
// Keep the window expanded while focused or being dragged, so dragging out
// of the collapsed bounds doesn't instantly collapse it midway through
// a move (which drops drag capture in ImGui).
if (ImGui::IsWindowFocused() ||
ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem)) {
mode_ = Mode::kExpanded;
}
UpdateSnap();
ImGui::End();
ImGui::PopStyleVar(2);
}
void RoleWindow::DrawExpanded(const SessionView& view,
SessionActions& actions) {
// No title bar; the window is still movable by dragging empty space
// (ImGui's default when ConfigWindowsMoveFromTitleBarOnly is off).
ImGui::Begin("Role", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar);
if (view.role == SessionRole::kClaiming) {
// The first roster or /ui claim outcome resolves this within a few
// hundred milliseconds of page load.
platform::CenteredBanner("CONNECTING", kConnectingColor);
ImGui::Separator();
DataRateLines(view);
} else if (view.role == SessionRole::kSpectating) {
DrawSpectatorContents(view, actions);
} else {
DrawControllerContents(view, actions);
}
UpdateSnap();
UpdateCollapse();
ImGui::End();
}
void RoleWindow::DrawSpectatorContents(const SessionView& view,
SessionActions& actions) {
// Control group.
platform::CenteredBanner("SPECTATING", kSpectatingColor);
char queue_text[64];
if (view.queue_pos > 0) {
snprintf(queue_text, sizeof(queue_text), "Control Queue: Position %d of %d",
view.queue_pos, view.queue_len);
} else if (view.queue_len > 0) {
snprintf(queue_text, sizeof(queue_text), "Control Queue: %d waiting",
view.queue_len);
} else {
snprintf(queue_text, sizeof(queue_text), "Control Queue: (empty)");
}
platform::CenteredLine(queue_text, nullptr);
if (view.queue_pos == 0) {
if (ImGui::Button("Request control", kFullWidth)) {
actions.RequestControl();
}
} else {
const float half_width =
(ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) *
0.5f;
if (ImGui::Button("Leave Queue", ImVec2(half_width, 0.0f))) {
actions.LeaveQueue();
}
ImGui::SetItemTooltip("Abandons the control request.");
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.65f, 0.15f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
ImVec4(0.80f, 0.20f, 0.20f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive,
ImVec4(0.50f, 0.10f, 0.10f, 1.0f));
if (ImGui::Button("Steal Control", ImVec2(half_width, 0.0f))) {
actions.StealControl();
}
ImGui::PopStyleColor(3);
ImGui::SetItemTooltip("Takes control immediately, jumping the queue.");
}
// Data rate group.
ImGui::Separator();
DataRateLines(view);
// Camera group.
ImGui::Separator();
// TODO(matijak): Use the studio camera-selection UI here in future, so
// a spectator can also pick any camera defined in the model.
// Sized to its longest option; the default width would stretch the
// window past the CONTROLLING layout's size.
ImGui::SetNextItemWidth(ImGui::CalcTextSize("Follow Controller").x +
ImGui::GetFrameHeight() * 2.0f);
int cam_mode = view.camera_mode;
if (ImGui::Combo("Camera", &cam_mode,
"Free: tumble\0Free: wasd\0Follow Controller\0")) {
actions.SetCameraMode(cam_mode);
}
}
void RoleWindow::DrawControllerContents(const SessionView& view,
SessionActions& actions) {
// Control group.
platform::CenteredBanner("CONTROLLING", kControllingColor);
if (view.queue_len > 0) {
char queue_text[64];
snprintf(queue_text, sizeof(queue_text), ">> Control Queue: %d waiting <<",
view.queue_len);
platform::CenteredLine(queue_text, &kQueueColor);
} else {
platform::CenteredLine("Control Queue: (empty)", nullptr);
}
if (ImGui::Button("Release control", kFullWidth)) {
actions.ReleaseControl();
}
// Data rate group.
ImGui::Separator();
DataRateLines(view);
// Session settings group.
ImGui::Separator();
// Local edits win for a grace period (the roster round trip takes a
// few frames and typing takes longer); afterwards the server's value
// is the truth.
if (max_spectators_edit_ < 0 ||
ImGui::GetTime() - max_spectators_edit_time_ > 1.5) {
max_spectators_edit_ = view.max_spectators;
}
ImGui::SetNextItemWidth(100.0f);
if (ImGui::InputInt("Max Spectators", &max_spectators_edit_)) {
max_spectators_edit_time_ = ImGui::GetTime();
max_spectators_edit_ = std::clamp(max_spectators_edit_, 0, 32);
actions.SetMaxSpectators(max_spectators_edit_);
}
if (ImGui::IsItemActive()) {
max_spectators_edit_time_ = ImGui::GetTime();
}
}
// Collapse when the mouse leaves the window, with a short grace period.
// A popup (e.g. the camera combo's dropdown) is a separate window, so
// moving the mouse into it unhovers this one; keep the window expanded
// while one of its popups is open, and give brief excursions time to
// come back before snapping shut. On page load the window instead stays
// expanded for a few seconds (the intro); the first hover ends the
// intro, so a quick swipe over the window dismisses it early.
void RoleWindow::UpdateCollapse() {
const bool popup_open = ImGui::IsPopupOpen(
"", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
if (popup_open || dragging_ ||
ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) ||
ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem)) {
mode_ = Mode::kExpanded; // The first hover ends the intro.
hover_time_ = ImGui::GetTime();
} else if (mode_ == Mode::kIntro) {
if (intro_start_ == 0) {
intro_start_ = ImGui::GetTime();
} else if (ImGui::GetTime() - intro_start_ > kIntroExpandedSec) {
mode_ = Mode::kCollapsed;
}
} else if (ImGui::GetTime() - hover_time_ > kCollapseGraceSec) {
mode_ = Mode::kCollapsed;
}
}
} // namespace mujoco::studio
@@ -0,0 +1,113 @@
// 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.
// The web viewer's local (browser-drawn) UI: the role window and the
// DISCONNECTED notices. Deliberately decoupled from the app: it reads a
// SessionView snapshot and reports user intent through the SessionActions
// interface (both defined in web_client_session.h), so drawing depends
// only on ImGui.
#ifndef MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_LOCAL_UI_H_
#define MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_LOCAL_UI_H_
#include <imgui.h>
#include "web_client_session.h"
namespace mujoco::studio {
// The screen-centered DISCONNECTED notices: a big red banner over white
// explanation lines, shown for deliberate server closes (session full,
// inactivity) and for server silence. The two never show at the same time:
// a close code suppresses the silence notice.
class DisconnectNotice {
public:
// seconds_since_last_payload is negative before the first payload.
void Draw(int server_close_code, double seconds_since_last_payload,
bool reload_pending);
private:
// How long the state stream (~60Hz while the Python side is alive) may go
// silent before the notice appears. A model-change restart resumes
// traffic in about a second and should not flash the notice.
static constexpr double kServerSilenceNoticeSec = 3.0;
// True while the server-not-reachable notice is up (log once per outage).
bool logged_ = false;
};
// The role window: a collapsed role pill that expands on hover into the
// session panel (role banner, control queue, data rates, per-role
// settings). Owns its presentation state — mode, intro/hover timers, drag
// and snap anchoring, edit grace.
class RoleWindow {
public:
void Draw(const SessionView& view, SessionActions& actions);
private:
// Presentation state machine: the window starts in the intro (expanded
// for a few seconds so new users see it exists and learn it expands on
// hover); the first hover ends the intro.
enum class Mode {
kIntro = 0, // Expanded on page load, until the timer or first hover.
kExpanded, // Expanded while hovered (plus a short leave grace).
kCollapsed, // The role pill; expands on hover.
};
// Where the window is anchored on the page. It is always draggable;
// releasing a drag near an anchor snaps to it, anywhere else leaves the
// window Free at its dropped position.
enum Anchor {
kAnchorFree = 0,
kAnchorTopLeft, // The anchors must stay contiguous from here on.
kAnchorTopMid,
kAnchorTopRight,
kAnchorBottomLeft,
kAnchorBottomMid,
kAnchorBottomRight,
};
static constexpr double kCollapseGraceSec = 0.2;
static constexpr double kIntroExpandedSec = 5.0;
static constexpr float kEdgeMargin = 10.0f;
// Pivots keeping the whole window on-screen kEdgeMargin off the
// anchoring edges; indexed by Anchor - kAnchorTopLeft, like anchor_pos_.
static constexpr ImVec2 kAnchorPivot[6] = {
{0.0f, 0.0f}, {0.5f, 0.0f}, {1.0f, 0.0f},
{0.0f, 1.0f}, {0.5f, 1.0f}, {1.0f, 1.0f},
};
void ComputeAnchorPositions();
void UpdateSnap();
void UpdateCollapse();
static void DataRateLines(const SessionView& view);
void DrawCollapsed(const SessionView& view);
void DrawExpanded(const SessionView& view, SessionActions& actions);
void DrawSpectatorContents(const SessionView& view, SessionActions& actions);
void DrawControllerContents(const SessionView& view, SessionActions& actions);
Mode mode_ = Mode::kIntro;
Anchor anchor_ = kAnchorTopMid;
ImVec2 anchor_pos_[6]; // Recomputed each frame (canvas resizes).
double intro_start_ = 0; // 0 until the expanded window first draws.
double hover_time_ = 0; // Feeds the collapse grace period.
bool dragging_ = false;
// Max Spectators edit grace: the local value wins over the roster until
// max_spectators_edit_time_ is old enough (see the InputInt).
int max_spectators_edit_ = -1;
double max_spectators_edit_time_ = -1e9;
};
} // namespace mujoco::studio
#endif // MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_LOCAL_UI_H_
@@ -0,0 +1,771 @@
// 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.
#include "web_client_remote_ui.h"
#include <algorithm>
#include <cstring>
#include <string>
#include "google/logging.h"
namespace mujoco::studio {
using namespace NetImgui::Internal;
namespace {
// Delta-compress the GUI stream (relayed to the client via CmdInput).
constexpr bool kUseCompression = true;
void LogUnmappedTexture(
RemoteUi::ClientTextureID client_tex_id, size_t map_size, uint32_t draw_idx,
const std::unordered_map<RemoteUi::ClientTextureID, uintptr_t>& tex_map) {
VLOG(1, "DrawFrame: UNMAPPED client_tex_id=%lu, map_size=%zu, draw#=%u",
static_cast<unsigned long>(client_tex_id), map_size, draw_idx);
std::string keys_str = "";
for (auto& kv : tex_map) {
keys_str += " " + std::to_string(kv.first) +
"(fil=" + std::to_string(kv.second) + ")";
}
VLOG(1, " map keys:%s", keys_str.c_str());
}
void LogCmdReceived(CmdHeader::eCommands cmd_type, uint32_t cmd_size,
int draw_frames, int textures,
const PendingCom& pending_receive) {
if (cmd_type == CmdHeader::eCommands::Version) {
const CmdVersion* ver =
reinterpret_cast<const CmdVersion*>(pending_receive.pCommand);
LOG(Info,
"Received CmdVersion from client: name='%s', version=%d, wchar_size=%d",
ver->mClientName, static_cast<int>(ver->mVersion), ver->mWCharSize);
} else if (cmd_type == CmdHeader::eCommands::DrawFrame) {
VLOG(2, "Received DrawFrame #%d (size=%u)", draw_frames, cmd_size);
} else if (cmd_type == CmdHeader::eCommands::Texture) {
VLOG(2, "Received Texture #%d (size=%u)", textures, cmd_size);
} else if (cmd_type == CmdHeader::eCommands::Background) {
VLOG(2, "Received Background cmd (size=%u)", cmd_size);
} else if (cmd_type != CmdHeader::eCommands::Count &&
cmd_type != CmdHeader::eCommands::Clipboard &&
cmd_type != CmdHeader::eCommands::Input) {
VLOG(2, "Received UNKNOWN cmd: type=%d, size=%u",
static_cast<int>(cmd_type), cmd_size);
}
}
} // namespace
void RemoteUi::Connect(const std::string& url) {
if (socket_) {
Network::Disconnect(socket_);
socket_ = nullptr;
}
// The port argument is unused when a full URL is passed.
LOG(Info, "Connecting to WebSocket at %s", url.c_str());
socket_ = Network::Connect(url.c_str(), 0);
LOG(Info, "Network::Connect returned: socket=%p",
static_cast<void*>(socket_));
}
RemoteUi::ReadyState RemoteUi::ConnectionState() const {
return Network::GetReadyState(socket_);
}
int RemoteUi::CloseCode() const { return Network::GetCloseCode(socket_); }
void RemoteUi::ReceiveAndProcessCommands(int frame) {
if (!socket_) return;
const ReadyState state = ConnectionState();
if (last_state_ != state) {
LOG(Info, "WebSocket status changed: '%s' -> '%s' (frame %d)",
Network::ReadyStateName(last_state_), Network::ReadyStateName(state),
frame);
last_state_ = state;
}
VLOG(1,
"Frame %d: status='%s', handshake=%s, cmds=%d, draws=%d, textures=%d, "
"has_draw_data=%s",
frame, Network::ReadyStateName(state),
handshake_sent_ ? "sent" : "not_sent", total_cmds_received_,
draw_frames_received_, textures_received_,
remote_draw_data_ != nullptr ? "yes" : "no");
if (state == ReadyState::kOpen) {
if (!handshake_sent_) {
CmdVersion cmd_version;
StringCopy(cmd_version.mClientName, "MuJoCo Web Viewer");
LOG(Info,
"Sending CmdVersion handshake: size=%u, type=%d, version=%d, "
"wchar_size=%d, name='%s'",
cmd_version.mSize, static_cast<int>(cmd_version.mType),
static_cast<int>(cmd_version.mVersion), cmd_version.mWCharSize,
cmd_version.mClientName);
PendingCom pending_send;
pending_send.pCommand = &cmd_version;
pending_send.SizeCurrent = 0;
int send_attempts = 0;
while (!pending_send.IsDone() && !pending_send.IsError()) {
const size_t before = pending_send.SizeCurrent;
Network::DataSend(socket_, pending_send);
send_attempts++;
if (pending_send.SizeCurrent == before) {
// No progress: the socket already reads OPEN (from emscripten's
// ready state) but the send backend is not ready yet, because the
// open callback that sets mConnected has not run. DataSend then
// returns with neither progress nor error, so this loop would spin
// the browser's main thread forever. Stop and retry on a later
// frame instead (handshake_sent_ stays false below).
break;
}
}
LOG(Info,
"CmdVersion send: done=%s, error=%s, attempts=%d, bytes_sent=%zu",
pending_send.IsDone() ? "true" : "false",
pending_send.IsError() ? "true" : "false", send_attempts,
static_cast<size_t>(pending_send.SizeCurrent));
if (pending_send.IsDone()) {
handshake_sent_ = true;
// Fresh connection: the client may resume delta compression against
// a frame from a previous session; ask for an uncompressed keyframe.
request_keyframe_ = true;
}
}
} else {
if (handshake_sent_) {
LOG(Info, "Resetting handshake (status='%s')",
Network::ReadyStateName(state));
}
handshake_sent_ = false;
}
// Network receive — drain all available data in one frame.
const bool is_connected = (state == ReadyState::kOpen);
// If the connection has closed, discard any buffered data immediately
// rather than churning through stale commands for several seconds.
if (was_connected_ && !is_connected) {
LOG(Info, "Connection lost (status='%s'). Discarding buffered data.",
Network::ReadyStateName(state));
// Reset any in-progress receive.
if (pending_receive_.bAutoFree)
netImguiDeleteSafe(pending_receive_.pCommand);
pending_receive_ = PendingCom();
was_connected_ = false;
}
if (is_connected) was_connected_ = true;
int max_commands_per_frame = 64;
int cmds_this_frame = 0;
bool had_pending_data = Network::DataReceivePending(socket_);
if (had_pending_data) {
VLOG(1, "Frame %d: data pending on socket", frame);
}
while (is_connected && max_commands_per_frame-- > 0) {
if (pending_receive_.IsReady()) {
cmd_pending_read_ = CmdPendingRead();
pending_receive_.pCommand = &cmd_pending_read_;
pending_receive_.bAutoFree = false;
}
if (!Network::DataReceivePending(socket_)) break;
Network::DataReceive(socket_, pending_receive_);
if (pending_receive_.pCommand->mSize > sizeof(CmdPendingRead) &&
pending_receive_.pCommand == &cmd_pending_read_) {
VLOG(2, "Allocating %u bytes for incoming cmd type=%d",
pending_receive_.pCommand->mSize,
static_cast<int>(pending_receive_.pCommand->mType));
CmdPendingRead* cmd_header = reinterpret_cast<CmdPendingRead*>(
netImguiSizedNew<uint8_t>(pending_receive_.pCommand->mSize));
*cmd_header = cmd_pending_read_;
pending_receive_.pCommand = cmd_header;
pending_receive_.bAutoFree = true;
}
if (!pending_receive_.IsDone()) {
if (pending_receive_.IsError()) {
LOG(Error, "Receive ERROR: cmd_size=%u, got=%zu, type=%d",
pending_receive_.pCommand->mSize,
static_cast<size_t>(pending_receive_.SizeCurrent),
static_cast<int>(pending_receive_.pCommand->mType));
if (pending_receive_.bAutoFree)
netImguiDeleteSafe(pending_receive_.pCommand);
pending_receive_ = PendingCom();
}
continue;
}
// Command fully received — dispatch.
bytes_accum_ += pending_receive_.pCommand->mSize;
cmds_this_frame++;
total_cmds_received_++;
CmdHeader::eCommands cmd_type = pending_receive_.pCommand->mType;
LogCmdReceived(cmd_type, pending_receive_.pCommand->mSize,
draw_frames_received_, textures_received_, pending_receive_);
if (cmd_type == CmdHeader::eCommands::Count) {
// CmdPendingRead sentinel — skip silently.
} else if (cmd_type == CmdHeader::eCommands::DrawFrame) {
draw_frames_received_++;
ProcessCmdDrawFrame(
reinterpret_cast<CmdDrawFrame*>(pending_receive_.pCommand));
} else if (cmd_type == CmdHeader::eCommands::Texture) {
textures_received_++;
ProcessCmdTexture(
reinterpret_cast<CmdTexture*>(pending_receive_.pCommand));
}
if (pending_receive_.bAutoFree)
netImguiDeleteSafe(pending_receive_.pCommand);
pending_receive_ = PendingCom();
}
VLOG(2, "Frame %d: processed %d commands", frame, cmds_this_frame);
}
void RemoteUi::FlushPendingTextures() {
for (auto& [tex_id, entry] : texture_cpu_) {
uintptr_t& local_tex = texture_map_[tex_id];
if (local_tex == 0 && !entry.pixels.empty()) {
local_tex = callbacks_.UploadTexture(
local_tex, reinterpret_cast<const std::byte*>(entry.pixels.data()),
entry.width, entry.height);
LOG(Info, "FlushPendingTextures: uploaded tex_id=%lu -> filament=%lu",
static_cast<unsigned long>(tex_id),
static_cast<unsigned long>(local_tex));
}
}
}
void RemoteUi::ProcessCmdTexture(CmdTexture* cmd_texture) {
if (!cmd_texture) return;
if (!cmd_texture->mpTextureData.IsPointer()) {
cmd_texture->mpTextureData.ToPointer();
}
ClientTextureID tex_id = cmd_texture->mTextureClientID;
VLOG(
1,
"ProcessCmdTexture: client_tex_id=%lu, status=%d, size=%ux%u, format=%d, "
"offset=%u,%u, map_size=%zu",
static_cast<unsigned long>(tex_id),
static_cast<int>(cmd_texture->mStatus),
static_cast<uint32_t>(cmd_texture->mWidth),
static_cast<uint32_t>(cmd_texture->mHeight),
static_cast<int>(cmd_texture->mFormat),
static_cast<uint32_t>(cmd_texture->mOffsetX),
static_cast<uint32_t>(cmd_texture->mOffsetY), texture_map_.size());
uintptr_t& local_tex = texture_map_[tex_id];
if (cmd_texture->mStatus == CmdTexture::eType::Destroy) {
if (local_tex != 0 && callbacks_.GpuReady()) {
// Pass nullptr pixels to destroy the GPU texture.
callbacks_.UploadTexture(local_tex, nullptr, 0, 0);
}
local_tex = 0;
texture_cpu_.erase(tex_id);
return;
}
uint8_t* pixels = cmd_texture->mpTextureData.Get();
if (!pixels) return;
// All of width/height/size come off the wire, so compute in size_t (no
// 32-bit overflow) and verify the command actually carries the pixel
// bytes before reading them — a short or corrupt command must not make
// the memcpy/expand below read past the command buffer.
const uint32_t patch_w = cmd_texture->mWidth;
const uint32_t patch_h = cmd_texture->mHeight;
const size_t pixel_count = static_cast<size_t>(patch_w) * patch_h;
const size_t data_size = cmd_texture->mSize >= sizeof(CmdTexture)
? cmd_texture->mSize - sizeof(CmdTexture)
: 0;
const size_t expected_rgba = pixel_count * 4;
// Detect actual format by data size, not format tag (which can be wrong).
const bool is_a8 = (cmd_texture->mFormat == 1) ||
(data_size == pixel_count && data_size != expected_rgba);
const size_t needed = is_a8 ? pixel_count : expected_rgba;
if (pixel_count == 0 || data_size < needed) {
LOG(Warning,
"ProcessCmdTexture: tex_id=%lu declares %ux%u but carries only %zu "
"bytes; dropping",
static_cast<unsigned long>(tex_id), patch_w, patch_h, data_size);
return;
}
// Convert incoming pixels to RGBA (Filament requires RGBA).
// For A8 font atlas data, expand each byte to (255, 255, 255, alpha).
std::vector<uint8_t> rgba_pixels(pixel_count * 4);
if (is_a8) {
for (size_t p = 0; p < pixel_count; ++p) {
rgba_pixels[p * 4 + 0] = 255;
rgba_pixels[p * 4 + 1] = 255;
rgba_pixels[p * 4 + 2] = 255;
rgba_pixels[p * 4 + 3] = pixels[p];
}
} else {
memcpy(rgba_pixels.data(), pixels, expected_rgba);
}
TextureEntry& entry = texture_cpu_[tex_id];
if (cmd_texture->mStatus == CmdTexture::eType::Create) {
// Full texture creation — store the CPU-side mirror.
entry.width = patch_w;
entry.height = patch_h;
entry.pixels = std::move(rgba_pixels);
} else {
// Partial update — patch the sub-region into the existing CPU mirror.
// If no CPU mirror exists (e.g. we missed the Create), skip.
if (entry.pixels.empty()) {
LOG(Warning,
"ProcessCmdTexture: partial update for tex_id=%lu "
"but no CPU mirror exists, skipping",
static_cast<unsigned long>(tex_id));
return;
}
const uint32_t off_x = cmd_texture->mOffsetX;
const uint32_t off_y = cmd_texture->mOffsetY;
// The patch rectangle is wire-supplied; reject one that would write past
// the stored mirror (e.g. dims desynced from a stale entry after a
// reconnect) rather than corrupting the heap.
if (static_cast<size_t>(off_x) + patch_w > entry.width ||
static_cast<size_t>(off_y) + patch_h > entry.height) {
LOG(Warning,
"ProcessCmdTexture: patch %ux%u at (%u,%u) exceeds mirror %ux%u "
"for tex_id=%lu; dropping",
patch_w, patch_h, off_x, off_y, entry.width, entry.height,
static_cast<unsigned long>(tex_id));
return;
}
for (uint32_t row = 0; row < patch_h; ++row) {
size_t dst_offset =
(static_cast<size_t>(off_y + row) * entry.width + off_x) * 4;
size_t src_offset = static_cast<size_t>(row) * patch_w * 4;
memcpy(&entry.pixels[dst_offset], &rgba_pixels[src_offset],
static_cast<size_t>(patch_w) * 4);
}
}
// Upload the full CPU-side texture to the GPU if the context is ready.
// If it isn't yet (model still loading), the texture stays in texture_cpu_
// and will be flushed by FlushPendingTextures() later.
if (callbacks_.GpuReady()) {
local_tex = callbacks_.UploadTexture(
local_tex, reinterpret_cast<const std::byte*>(entry.pixels.data()),
entry.width, entry.height);
VLOG(1, "uploadGuiImage for client_tex_id=%lu -> filament=%lu",
static_cast<unsigned long>(tex_id),
static_cast<unsigned long>(local_tex));
} else {
VLOG(1, "ProcessCmdTexture: buffered tex_id=%lu, deferring GPU upload",
static_cast<unsigned long>(tex_id));
}
}
// Keep in sync with ProcessCmdDrawFrame in the vendored
// netimgui/Code/ServerApp/Source/NetImguiServer_RemoteClient.cpp
void RemoteUi::ProcessCmdDrawFrame(CmdDrawFrame* cmd_draw_frame) {
if (!cmd_draw_frame) return;
// Take ownership to prevent pending_receive_ from deleting it prematurely.
pending_receive_.bAutoFree = false;
cmd_draw_frame->ToPointers();
if (cmd_draw_frame->mCompressed) {
if (last_uncompressed_frame_ != nullptr &&
(last_uncompressed_frame_->mFrameIndex + 1) ==
cmd_draw_frame->mFrameIndex) {
CmdDrawFrame* uncompressed_frame = DecompressCmdDrawFrame(
last_uncompressed_frame_.get(), cmd_draw_frame);
netImguiDeleteSafe(cmd_draw_frame);
cmd_draw_frame = uncompressed_frame;
} else {
// Missing previous / reference frame data. Ignore this delta-encoded
// drawframe and ask the client for a fresh uncompressed keyframe.
request_keyframe_ = true;
netImguiDeleteSafe(cmd_draw_frame);
return;
}
}
// Release previous cached frame and store current for the next delta
// decompression.
last_uncompressed_frame_.reset(cmd_draw_frame);
cmd_draw_frame->ToPointers();
RemoteDrawFrame* frame = netImguiNew<RemoteDrawFrame>();
frame->frame_index = cmd_draw_frame->mFrameIndex;
ImDrawData* draw_data = &frame->draw_data;
draw_data->Valid = true;
draw_data->TotalVtxCount =
static_cast<int>(cmd_draw_frame->mTotalVerticeCount);
draw_data->TotalIdxCount =
static_cast<int>(cmd_draw_frame->mTotalIndiceCount);
draw_data->DisplayPos.x = cmd_draw_frame->mDisplayArea[0];
draw_data->DisplayPos.y = cmd_draw_frame->mDisplayArea[1];
draw_data->DisplaySize.x =
cmd_draw_frame->mDisplayArea[2] - cmd_draw_frame->mDisplayArea[0];
draw_data->DisplaySize.y =
cmd_draw_frame->mDisplayArea[3] - cmd_draw_frame->mDisplayArea[1];
draw_data->FramebufferScale = ImGui::GetIO().DisplayFramebufferScale;
ImDrawList* cmd_list = draw_data->CmdLists[0];
cmd_list->IdxBuffer.resize(cmd_draw_frame->mTotalIndiceCount);
cmd_list->VtxBuffer.resize(cmd_draw_frame->mTotalVerticeCount);
cmd_list->CmdBuffer.resize(cmd_draw_frame->mTotalDrawCount);
// ImVector::resize() doesn't call constructors. Zero-init to ensure
// TexRef._TexData is NULL, not garbage.
memset(cmd_list->CmdBuffer.Data, 0,
cmd_list->CmdBuffer.Size * sizeof(ImDrawCmd));
cmd_list->Flags =
ImDrawListFlags_AllowVtxOffset | ImDrawListFlags_AntiAliasedLines |
ImDrawListFlags_AntiAliasedFill | ImDrawListFlags_AntiAliasedLinesUseTex;
constexpr float kPosRangeMin = static_cast<float>(ImguiVert::kPosRange_Min);
constexpr float kPosRangeMax = static_cast<float>(ImguiVert::kPosRange_Max);
constexpr float kUVRangeMin = static_cast<float>(ImguiVert::kUvRange_Min);
constexpr float kUVRangeMax = static_cast<float>(ImguiVert::kUvRange_Max);
if (cmd_draw_frame->mTotalDrawCount != 0) {
// WebGL 1.0/2.0 often uses uint16_t for ImDrawIdx. Check for potential
// overflow if the total vertex count exceeds the limit for 16-bit indices.
if (sizeof(ImDrawIdx) == 2 && cmd_draw_frame->mTotalVerticeCount > 65536) {
fprintf(stderr,
"WARNING: NetImgui WASM Viewer received a draw frame with %u "
"vertices. This exceeds the maximum of 65536 for 16-bit "
"ImDrawIdx, potentially causing rendering artifacts due to index "
"wrapping.\n",
cmd_draw_frame->mTotalVerticeCount);
}
uint32_t index_offset(0), vertex_offset(0);
ImDrawIdx* index_dst = &cmd_list->IdxBuffer[0];
ImDrawVert* vertex_dst = &cmd_list->VtxBuffer[0];
ImDrawCmd* command_dst = &cmd_list->CmdBuffer[0];
for (uint32_t i(0); i < cmd_draw_frame->mDrawGroupCount; ++i) {
const ImguiDrawGroup& draw_group = cmd_draw_frame->mpDrawGroups[i];
// Indices
const uint16_t* indices =
reinterpret_cast<const uint16_t*>(draw_group.mpIndices.Get());
if (draw_group.mBytePerIndex == sizeof(ImDrawIdx)) {
memcpy(index_dst, indices, draw_group.mIndiceCount * sizeof(ImDrawIdx));
} else {
for (uint32_t index_idx(0); index_idx < draw_group.mIndiceCount;
++index_idx) {
index_dst[index_idx] = static_cast<ImDrawIdx>(indices[index_idx]);
}
}
// Vertices — unpack quantized positions and UVs.
const ImguiVert* vertex_src = draw_group.mpVertices.Get();
for (uint32_t vtx_idx(0); vtx_idx < draw_group.mVerticeCount; ++vtx_idx) {
vertex_dst[vtx_idx].pos.x =
(static_cast<float>(vertex_src[vtx_idx].mPos[0]) *
(kPosRangeMax - kPosRangeMin)) /
static_cast<float>(0xFFFF) +
kPosRangeMin + draw_group.mReferenceCoord[0];
vertex_dst[vtx_idx].pos.y =
(static_cast<float>(vertex_src[vtx_idx].mPos[1]) *
(kPosRangeMax - kPosRangeMin)) /
static_cast<float>(0xFFFF) +
kPosRangeMin + draw_group.mReferenceCoord[1];
vertex_dst[vtx_idx].uv.x =
(static_cast<float>(vertex_src[vtx_idx].mUV[0]) *
(kUVRangeMax - kUVRangeMin)) /
static_cast<float>(0xFFFF) +
kUVRangeMin;
vertex_dst[vtx_idx].uv.y =
(static_cast<float>(vertex_src[vtx_idx].mUV[1]) *
(kUVRangeMax - kUVRangeMin)) /
static_cast<float>(0xFFFF) +
kUVRangeMin;
vertex_dst[vtx_idx].col = vertex_src[vtx_idx].mColor;
}
// Draw commands.
// NOTE: WebGL lacks glDrawElementsBaseVertex, so the backend's
// glDrawElements ignores VtxOffset. We bake the offset directly
// into the index values.
const ImguiDraw* draw_src = draw_group.mpDraws.Get();
for (uint32_t draw_idx(0); draw_idx < draw_group.mDrawCount; ++draw_idx) {
uint32_t vtx_off = draw_src[draw_idx].mVtxOffset + vertex_offset;
uint32_t idx_off = draw_src[draw_idx].mIdxOffset + index_offset;
uint32_t elem_count = draw_src[draw_idx].mIdxCount;
// Bake vertex offset into index values.
for (uint32_t ei = 0; ei < elem_count; ++ei) {
cmd_list->IdxBuffer[idx_off + ei] += static_cast<ImDrawIdx>(vtx_off);
}
float cx = std::max(
0.f, std::min(max_clip_[0], draw_src[draw_idx].mClipRect[0]));
float cy = std::max(
0.f, std::min(max_clip_[1], draw_src[draw_idx].mClipRect[1]));
float cz = std::max(
cx, std::min(max_clip_[0], draw_src[draw_idx].mClipRect[2]));
float cw = std::max(
cy, std::min(max_clip_[1], draw_src[draw_idx].mClipRect[3]));
command_dst[draw_idx].ClipRect.x = cx;
command_dst[draw_idx].ClipRect.y = cy;
command_dst[draw_idx].ClipRect.z = cz;
command_dst[draw_idx].ClipRect.w = cw;
command_dst[draw_idx].VtxOffset = 0; // Baked into indices
command_dst[draw_idx].IdxOffset = idx_off;
command_dst[draw_idx].ElemCount = elem_count;
command_dst[draw_idx].UserCallback = nullptr;
command_dst[draw_idx].UserCallbackData = nullptr;
// Map remote ClientTextureID -> local GL handle.
ClientTextureID client_tex_id = draw_src[draw_idx].mClientTexId;
auto it = texture_map_.find(client_tex_id);
if (it != texture_map_.end() && it->second != 0) {
command_dst[draw_idx].TexRef._TexData = nullptr;
command_dst[draw_idx].TexRef._TexID =
static_cast<ImTextureID>(it->second);
} else {
LogUnmappedTexture(client_tex_id, texture_map_.size(), draw_idx,
texture_map_);
// Skip draw commands with unmapped textures.
command_dst[draw_idx].ElemCount = 0;
}
}
index_dst += draw_group.mIndiceCount;
vertex_dst += draw_group.mVerticeCount;
command_dst += draw_group.mDrawCount;
index_offset += draw_group.mIndiceCount;
vertex_offset += draw_group.mVerticeCount;
}
}
// Update internal write pointers to satisfy ImGui's draw list sanity checks.
// AddDrawListToDrawDataEx asserts that _VtxWritePtr/_IdxWritePtr point to the
// end of their respective buffers. Since we populated them via
// resize()+memcpy (bypassing ImGui's PrimReserve API), we must fix up these
// pointers manually.
cmd_list->_VtxWritePtr = cmd_list->VtxBuffer.Data + cmd_list->VtxBuffer.Size;
cmd_list->_IdxWritePtr = cmd_list->IdxBuffer.Data + cmd_list->IdxBuffer.Size;
cmd_list->_VtxCurrentIdx = cmd_list->VtxBuffer.Size;
if (remote_draw_data_ == nullptr) {
LOG(Info, "First remote draw frame applied (%d cmds, %d vtx)",
draw_data->CmdLists[0]->CmdBuffer.Size, draw_data->TotalVtxCount);
}
remote_draw_data_.reset(frame);
}
// Keep in sync with CaptureImguiInput in the vendored
// netimgui/Code/ServerApp/Source/NetImguiServer_RemoteClient.cpp
void RemoteUi::CaptureAndSendInput() {
if (!socket_) return;
if (ConnectionState() != ReadyState::kOpen) return;
// Capture input from Dear ImGui.
const ImGuiIO& io = ImGui::GetIO();
// When the local UI wants to capture mouse or keyboard, suppress the
// corresponding input from being forwarded to the remote client. This
// prevents clicks/keys meant for the local status overlay (or any other
// local window) from leaking through to the UI server.
const bool local_wants_mouse = io.WantCaptureMouse;
const bool local_wants_keyboard = io.WantCaptureKeyboard;
{
// Only accumulate characters when the local UI is NOT capturing keyboard.
if (!local_wants_keyboard) {
const size_t initial_size = pending_input_chars_.size();
const size_t added_char = io.InputQueueCharacters.size();
if (added_char) {
pending_input_chars_.resize(initial_size + added_char);
memcpy(&pending_input_chars_[initial_size],
io.InputQueueCharacters.Data, added_char * sizeof(ImWchar));
}
}
// Gate scroll ACCUMULATION on local capture, the totals themselves are
// still sent every frame. This implements the "FREEZING" mentioned in the
// CmdInput wheel comment below.
if (!local_wants_mouse) {
mouse_wheel_pos_[0] += io.MouseWheel;
mouse_wheel_pos_[1] += io.MouseWheelH;
}
}
CmdInput cmd_input;
cmd_input.mScreenSize[0] = static_cast<uint16_t>(io.DisplaySize.x);
cmd_input.mScreenSize[1] = static_cast<uint16_t>(io.DisplaySize.y);
// An unstable screen size forces the remote UI to relayout every frame
// (visible as UI flicker), so make changes loud.
if (cmd_input.mScreenSize[0] != last_screen_size_[0] ||
cmd_input.mScreenSize[1] != last_screen_size_[1]) {
LOG(Info, "Screen size sent to client changed: %ux%u -> %ux%u",
last_screen_size_[0], last_screen_size_[1], cmd_input.mScreenSize[0],
cmd_input.mScreenSize[1]);
last_screen_size_[0] = cmd_input.mScreenSize[0];
last_screen_size_[1] = cmd_input.mScreenSize[1];
}
cmd_input.mFontDPIScaling = 1.f;
cmd_input.mDesiredFps = 60.0f;
cmd_input.mCompressionUse = kUseCompression;
cmd_input.mCompressionSkip = request_keyframe_;
// NetImgui wheel fields are lifetime running totals, not per-frame deltas:
// the receiving side derives each frame's scroll as the difference between
// the current total and the previous total. So scroll is suppressed during
// local UI capture by FREEZING the totals (accumulation above is gated on
// !local_wants_mouse), while still sending them every frame. Sending 0
// instead would rewind the receiver's baseline and produce two huge spurious
// deltas: -total when capture starts (wild zoom out as the local window
// expands) and +total when it ends (snap back on collapse). Note the
// deliberate asymmetry with the mouse position below, which IS per-frame
// absolute and can simply be parked while captured.
cmd_input.mMouseWheelVert = mouse_wheel_pos_[0];
cmd_input.mMouseWheelHoriz = mouse_wheel_pos_[1];
if (!local_wants_mouse) {
cmd_input.mMousePos[0] = static_cast<int16_t>(io.MousePos.x);
cmd_input.mMousePos[1] = static_cast<int16_t>(io.MousePos.y);
} else {
// Park the mouse off-screen so the remote side doesn't think we're
// hovering over anything.
cmd_input.mMousePos[0] = -1;
cmd_input.mMousePos[1] = -1;
}
// Mouse button inputs. This static_assert detects when a Dear ImGui update
// changes ImGuiMouseButton, which requires updating NetImgui's enum copy.
static_assert(
static_cast<int>(CmdInput::NetImguiMouseButton::ImGuiMouseButton_COUNT) ==
static_cast<int>(ImGuiMouseButton_::ImGuiMouseButton_COUNT),
"Update the NetImgui enum to match the updated Dear ImGui enum");
cmd_input.mMouseDownMask = 0;
if (!local_wants_mouse) {
cmd_input.mMouseDownMask |=
ImGui::IsMouseDown(ImGuiMouseButton_::ImGuiMouseButton_Left)
? 1 << CmdInput::ImGuiMouseButton_Left
: 0;
cmd_input.mMouseDownMask |=
ImGui::IsMouseDown(ImGuiMouseButton_::ImGuiMouseButton_Right)
? 1 << CmdInput::ImGuiMouseButton_Right
: 0;
cmd_input.mMouseDownMask |=
ImGui::IsMouseDown(ImGuiMouseButton_::ImGuiMouseButton_Middle)
? 1 << CmdInput::ImGuiMouseButton_Middle
: 0;
cmd_input.mMouseDownMask |=
ImGui::IsMouseDown(3) ? 1 << CmdInput::ImGuiMouseButton_Extra1 : 0;
cmd_input.mMouseDownMask |=
ImGui::IsMouseDown(4) ? 1 << CmdInput::ImGuiMouseButton_Extra2 : 0;
}
// Keyboard inputs. These static_asserts detect when a Dear ImGui update
// changes ImGuiKey, which requires updating NetImgui's enum copy to match.
#define EnumKeynameTest(KEYNAME) \
static_cast<int>(CmdInput::NetImguiKeys::KEYNAME) == \
static_cast<int>(ImGuiKey::KEYNAME - ImGuiKey::ImGuiKey_NamedKey_BEGIN), \
"Update the NetImgui enum to match the updated Dear ImGui enum"
static_assert(
CmdInput::NetImguiKeys::ImGuiKey_COUNT ==
(ImGuiKey_NamedKey_END - ImGuiKey_NamedKey_BEGIN),
"Update the NetImgui enum to match the updated Dear ImGui enum");
static_assert(EnumKeynameTest(ImGuiKey_Tab));
static_assert(EnumKeynameTest(ImGuiKey_Escape));
static_assert(EnumKeynameTest(ImGuiKey_RightSuper));
static_assert(EnumKeynameTest(ImGuiKey_Apostrophe));
static_assert(EnumKeynameTest(ImGuiKey_Keypad0));
static_assert(EnumKeynameTest(ImGuiKey_CapsLock));
static_assert(EnumKeynameTest(ImGuiKey_ReservedForModCtrl));
static_assert(EnumKeynameTest(ImGuiKey_ReservedForModShift));
static_assert(EnumKeynameTest(ImGuiKey_ReservedForModAlt));
static_assert(EnumKeynameTest(ImGuiKey_ReservedForModSuper));
#undef EnumKeynameTest
// Save every keydown status to out bitmask — only when local UI is not
// capturing keyboard.
if (!local_wants_keyboard) {
uint64_t value_mask(0);
for (uint32_t i(0); i < ImGuiKey::ImGuiKey_NamedKey_COUNT; ++i) {
value_mask |=
ImGui::IsKeyDown(static_cast<ImGuiKey>(ImGuiKey_NamedKey_BEGIN + i))
? 0x0000000000000001ull << (i % 64)
: 0;
if (((i % 64) == 63) || i == (ImGuiKey::ImGuiKey_NamedKey_COUNT - 1)) {
cmd_input.mInputDownMask[i / 64] = value_mask;
value_mask = 0;
}
}
}
// When local UI captures keyboard, mInputDownMask and mInputAnalog stay
// zero-initialized from the CmdInput constructor.
// Copy waiting characters inputs — only when local UI is not capturing
// keyboard. When captured, pending chars are discarded to avoid buffering
// stale input that would replay when focus returns to remote.
if (!local_wants_keyboard) {
size_t added_key_count = std::min<size_t>(
ArrayCount(cmd_input.mKeyChars) - cmd_input.mKeyCharCount,
pending_input_chars_.size());
if (added_key_count) {
memcpy(&cmd_input.mKeyChars[cmd_input.mKeyCharCount],
&pending_input_chars_[0], added_key_count * sizeof(ImWchar));
cmd_input.mKeyCharCount += static_cast<uint16_t>(added_key_count);
size_t char_remain_count = pending_input_chars_.size() - added_key_count;
if (char_remain_count > 0) {
memcpy(&pending_input_chars_[0], &pending_input_chars_[added_key_count],
char_remain_count * sizeof(ImWchar));
}
pending_input_chars_.resize(char_remain_count);
}
if (cmd_input.mKeyCharCount > 0) {
VLOG(1, "[web_client_remote_ui.cc] Queued %u characters to send\n",
cmd_input.mKeyCharCount);
}
} else {
// Discard any pending characters that were accumulated while local UI
// had keyboard focus.
pending_input_chars_.clear();
}
PendingCom pending_send;
pending_send.pCommand = &cmd_input;
pending_send.SizeCurrent = 0;
Network::DataSend(socket_, pending_send);
if (pending_send.IsDone() && cmd_input.mCompressionSkip) {
request_keyframe_ = false;
}
}
void RemoteUi::Shutdown() {
remote_draw_data_.reset();
last_uncompressed_frame_.reset();
if (socket_) {
Network::Disconnect(socket_);
socket_ = nullptr;
}
}
} // namespace mujoco::studio
@@ -0,0 +1,193 @@
// 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.
// The NetImgui client side of the web viewer. Receives the headless Studio UI
// (draw frames/textures) over the /ui WebSocket and replies with browser input.
#ifndef MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_REMOTE_UI_H_
#define MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_REMOTE_UI_H_
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include <imgui.h>
#include <NetImgui_Api.h>
#include <NetImgui_CmdPackets.h>
#include <NetImgui_Network.h>
#include "google/network_status.h"
namespace mujoco::studio {
// Deleter for objects allocated by NetImgui.
struct NetImguiDeleter {
template <typename T>
void operator()(T* ptr) const {
NetImgui::Internal::netImguiDelete(ptr);
}
};
// The browser side of the streamed ImGui UI: connects to the UI WebSocket,
// receives NetImgui draw frames and textures from the Python side, assembles
// them into ImGui draw data for rendering, and sends the browser's input back.
class RemoteUi {
public:
using SocketInfo = NetImgui::Internal::Network::SocketInfo;
using ReadyState = NetImgui::Internal::Network::ReadyState;
using ClientTextureID = NetImgui::Internal::ClientTextureID;
// The renderer-facing callbacks of the link; the app implements them once.
// The link reaches the GPU only through it, so the protocol logic stays
// renderer-agnostic.
class Callbacks {
public:
virtual ~Callbacks() = default;
// Uploads a full RGBA texture and returns the GPU handle; nullptr
// pixels destroy the texture backing `current`.
virtual uintptr_t UploadTexture(uintptr_t current, const std::byte* rgba,
uint32_t width, uint32_t height) = 0;
// True once the GPU context can accept texture uploads.
virtual bool GpuReady() = 0;
};
explicit RemoteUi(Callbacks& callbacks) : callbacks_(callbacks) {}
~RemoteUi() { Shutdown(); }
// (Re)connects to the UI WebSocket. Disconnects any existing socket first.
void Connect(const std::string& url);
bool HasSocket() const { return socket_ != nullptr; }
// Current socket state; browser WebSockets connect and close
// asynchronously, so this can differ from HasSocket() (see
// google/network_status.h).
ReadyState ConnectionState() const;
// The WebSocket close code once the socket has closed, else 0.
int CloseCode() const;
// Remote clip rects are clamped to this (logical pixels); set each frame
// before ReceiveAndProcessCommands().
void SetMaxClip(float width, float height) {
max_clip_[0] = width;
max_clip_[1] = height;
}
// Sends the CmdVersion handshake when the socket is newly open, then drains
// all incoming commands (draw frames, textures) for this frame.
void ReceiveAndProcessCommands(int frame);
// Captures ImGui input and sends it to the remote client. Must run before
// ImGui::EndFrame(), because EndFrame() clears the io.InputQueueCharacters
// queue this reads (note that ImGui::Render() calls EndFrame() implicitly).
void CaptureAndSendInput();
// Uploads CPU-buffered textures (e.g. the font atlas) that arrived before
// the GPU context became available.
void FlushPendingTextures();
void Shutdown();
// The latest assembled remote draw data, or nullptr before the first frame.
ImDrawData* RemoteDrawData() {
return remote_draw_data_ ? &remote_draw_data_->draw_data : nullptr;
}
// Returns the bytes received since the last call and resets the counter.
uint64_t ConsumeByteCount() {
uint64_t bytes = bytes_accum_;
bytes_accum_ = 0;
return bytes;
}
void ProcessCmdDrawFrame(NetImgui::Internal::CmdDrawFrame* cmd);
void ProcessCmdTexture(NetImgui::Internal::CmdTexture* cmd);
private:
// An assembled remote frame: an ImDrawData plus the single command list it
// points at (a plain ImDrawData only references externally-owned lists).
struct RemoteDrawFrame {
RemoteDrawFrame()
: command_list(ImGui::GetCurrentContext()
? ImGui::GetDrawListSharedData()
: nullptr) {
draw_data.CmdLists.push_back(&command_list);
draw_data.CmdListsCount = 1;
}
ImDrawData draw_data;
ImDrawList command_list;
uint64_t frame_index = 0;
};
Callbacks& callbacks_;
float max_clip_[2] = {0.0f, 0.0f};
// --- Connection-scoped state, reset on every (re)connect. ----------------
SocketInfo* socket_ = nullptr;
bool handshake_sent_ = false;
bool was_connected_ = false;
ReadyState last_state_ = ReadyState::kDisconnected;
NetImgui::Internal::PendingCom pending_receive_;
NetImgui::Internal::CmdPendingRead cmd_pending_read_;
// When true, the next CmdInput asks the client to send one uncompressed
// draw frame (CmdInput::mCompressionSkip). Set on (re)connect and whenever
// a delta frame arrives whose reference frame we don't have — without it,
// a browser joining mid-stream drops every delta-compressed frame forever
// and the remote UI never appears. Mirrors mbCompressionSkipOncePending in
// NetImguiServer RemoteClient.
bool request_keyframe_ = true;
std::unique_ptr<NetImgui::Internal::CmdDrawFrame, NetImguiDeleter>
last_uncompressed_frame_;
// --- Session state. -------------------------------------------------------
std::unique_ptr<RemoteDrawFrame, NetImguiDeleter> remote_draw_data_;
std::unordered_map<ClientTextureID, uintptr_t> texture_map_;
// CPU-side texture cache so partial updates from NetImgui can be patched into
// these buffers before re-uploading the full texture (partial updates into
// GPU textures are not supported by Filament).
struct TextureEntry {
std::vector<uint8_t> pixels; // Full RGBA pixel data
uint32_t width = 0;
uint32_t height = 0;
};
std::unordered_map<ClientTextureID, TextureEntry> texture_cpu_;
// Persistent input state for character accumulation across frames.
std::vector<ImWchar> pending_input_chars_;
float mouse_wheel_pos_[2] = {0.0f, 0.0f};
uint16_t last_screen_size_[2] = {0, 0};
// --- Telemetry. -----------------------------------------------------------
uint64_t bytes_accum_ = 0;
int total_cmds_received_ = 0;
int draw_frames_received_ = 0;
int textures_received_ = 0;
};
} // namespace mujoco::studio
#endif // MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_REMOTE_UI_H_
@@ -0,0 +1,311 @@
// 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.
#include "web_client_session.h"
#include <emscripten.h>
#include <cstdio>
#include <cstring>
#include "google/logging.h"
namespace mujoco::studio {
// Session-channel messages sent/received as text frames on the state WebSocket.
constexpr char kMsgRequestControl[] = "request_control";
constexpr char kMsgLeaveQueue[] = "leave_queue";
constexpr char kMsgForceControl[] = "force_control";
constexpr char kMsgHeartbeat[] = "heartbeat";
constexpr char kMsgStateAck[] = "state_ack";
constexpr char kMsgGrant[] = "grant";
constexpr char kMsgMaxSpectatorsPrefix[] = "max_spectators=";
// Liveness heartbeat period. A hidden tab's rendering loop stops, so Update()
// and the heartbeat stop with it. The server kicks spectators (and releases a
// controller with a waiting queue) on silence.
constexpr double kHeartbeatSec = 30.0;
// Minimum time between /ui claim retries (and between role machine steps).
constexpr double kUiRetrySec = 1.0;
// After this many consecutive rejected claims the page stops claiming and
// settles into spectating.
constexpr int kMaxUiRejects = 3;
EM_BOOL Session::OnWsMessage(int event_type,
const EmscriptenWebSocketMessageEvent* event,
void* user_data) {
auto* session = static_cast<Session*>(user_data);
session->server_close_code_ = 0; // Accepted; hide the disconnect notice.
if (event->isText) {
// Text frames carry session metadata; emscripten null-terminates them.
session->OnSessionText(reinterpret_cast<const char*>(event->data));
} else {
session->HandleMessage(event->data, event->numBytes);
// Flow control: the server keeps at most one state payload in flight and
// sends the next (freshest) one only after this ack. Without it, a slow
// link buffers seconds of stale payloads in the socket and the whole viewer
// lags by that queue.
session->SendText(kMsgStateAck);
}
return EM_TRUE;
}
EM_BOOL Session::OnWsOpen(int event_type,
const EmscriptenWebSocketOpenEvent* event,
void* user_data) {
auto* session = static_cast<Session*>(user_data);
session->connected_ = true;
// server_close_code_ is NOT cleared here: a rejected connection also fires
// open before the server's closing code arrives. It clears on the first
// received message, which proves the server accepted us.
LOG(Info, "State WebSocket connected");
return EM_TRUE;
}
EM_BOOL Session::OnWsError(int event_type,
const EmscriptenWebSocketErrorEvent* event,
void* user_data) {
LOG(Error, "State WebSocket error");
return EM_TRUE;
}
EM_BOOL Session::OnWsClose(int event_type,
const EmscriptenWebSocketCloseEvent* event,
void* user_data) {
auto* session = static_cast<Session*>(user_data);
LOG(Info, "State WebSocket closed (code=%d)", event->code);
session->connected_ = false;
// Codes 4xxx are deliberate server-side closes (e.g. kWsCloseSessionFull).
// These conditions pass, so the GUI shows a notice while the reconnect loop
// retries at a slower pace.
if (event->code >= 4000 && event->code <= 4999) {
session->server_close_code_ = event->code;
LOG(Info, "Server ended this connection (code=%d); retrying slowly.",
event->code);
}
// Free the handle; without this, every closed socket (including each failed
// reconnect) leaks its handle and callback registrations in Emscripten's
// socket table. Session (this) is a stable global, so the user_data of any
// already-queued event stays valid; detaching the callbacks first stops them
// firing on the freed handle.
session->CloseSocket();
return EM_TRUE;
}
void Session::CloseSocket() {
if (socket_ <= 0) return;
emscripten_websocket_set_onopen_callback(socket_, nullptr, nullptr);
emscripten_websocket_set_onmessage_callback(socket_, nullptr, nullptr);
emscripten_websocket_set_onerror_callback(socket_, nullptr, nullptr);
emscripten_websocket_set_onclose_callback(socket_, nullptr, nullptr);
emscripten_websocket_delete(socket_);
socket_ = 0;
connected_ = false;
}
void Session::Connect(const std::string& url) {
// Drop any lingering socket first so a reconnect cannot leak the old one.
CloseSocket();
EmscriptenWebSocketCreateAttributes attr;
emscripten_websocket_init_create_attributes(&attr);
attr.url = url.c_str();
attr.protocols = nullptr;
attr.createOnMainThread = EM_TRUE;
socket_ = emscripten_websocket_new(&attr);
if (socket_ <= 0) {
LOG(Error, "Failed to create state WebSocket");
return;
}
emscripten_websocket_set_onopen_callback(socket_, this, OnWsOpen);
emscripten_websocket_set_onmessage_callback(socket_, this, OnWsMessage);
emscripten_websocket_set_onerror_callback(socket_, this, OnWsError);
emscripten_websocket_set_onclose_callback(socket_, this, OnWsClose);
LOG(Info, "State WebSocket connecting to %s", url.c_str());
}
void Session::SendText(const char* text) {
if (socket_ && connected_) {
emscripten_websocket_send_utf8_text(socket_, text);
}
}
void Session::SetRole(SessionRole role) {
role_ = role;
// Warning: clang-format splits `!==` into `!= =`, so avoid that syntax!
EM_ASM(
{ Module.isSpectator = !!$0; },
role == SessionRole::kControlling ? 0 : 1);
}
bool ParseRoster(const char* text, Roster* roster) {
Roster parsed;
char role[16] = {0};
if (sscanf(text,
"viewers=%d;role=%15[^;];queue_pos=%d;queue_len=%d;"
"max_spectators=%d",
&parsed.viewers, role, &parsed.queue_pos, &parsed.queue_len,
&parsed.max_spectators) != 5) {
return false;
}
parsed.spectator = strcmp(role, "spectator") == 0;
*roster = parsed;
return true;
}
void Session::OnSessionText(const char* text) {
Roster roster;
if (ParseRoster(text, &roster)) {
if (roster.viewers != roster_.viewers ||
roster.queue_pos != roster_.queue_pos ||
roster.queue_len != roster_.queue_len) {
LOG(Info, "Session roster: %s", text);
}
roster_ = roster;
// The roster is authoritative about this page's role. Settling on it
// (rather than after several rejected /ui retries) makes the SPECTATING
// banner appear within the first roster (~200ms). Only while claiming with
// /ui closed: an in-flight claim must not be aborted, and an established
// controller is never demoted here (the kWsCloseControllerTaken close path
// handles that).
if (role_ == SessionRole::kClaiming && roster.spectator) {
if (remote_ui_state_ == RemoteUiState::kNoSocket ||
remote_ui_state_ == RemoteUiState::kClosedOrError) {
LOG(Info, "Roster says spectator; settling");
SetRole(SessionRole::kSpectating);
callbacks_.ShutdownRemoteUi();
}
}
} else if (strcmp(text, kMsgGrant) == 0) {
// The controller slot is reserved for this page. The role flips to
// kControlling when the claim's socket opens.
LOG(Info, "Control granted; claiming the controller slot");
SetRole(SessionRole::kClaiming);
ui_reject_count_ = 0;
callbacks_.ConnectRemoteUi();
// Mark the claim in flight now: the roster broadcast right after the grant
// arrives before the next frame refreshes remote_ui_state_, and the settle
// rule above must not shut down the fresh claim.
remote_ui_state_ = RemoteUiState::kConnecting;
}
}
void Session::FillView(SessionView* view) const {
view->role = role_;
view->viewers = roster_.viewers;
view->queue_pos = roster_.queue_pos;
view->queue_len = roster_.queue_len;
view->max_spectators = roster_.max_spectators;
}
void Session::Update() {
const double now = emscripten_get_now() / 1000.0;
if (now - last_heartbeat_time_ >= kHeartbeatSec) {
last_heartbeat_time_ = now;
SendText(kMsgHeartbeat);
}
}
void Session::HandleRemoteUiState(RemoteUiState state, int close_code) {
remote_ui_state_ = state;
// No stream to manage, nothing left to claim (settled spectator), or the
// session itself is down or reloading — the /state policies rule then.
if (state == RemoteUiState::kNoSocket || role_ == SessionRole::kSpectating ||
reload_pending_ || server_close_code_ != 0) {
return;
}
const double now = emscripten_get_now() / 1000.0;
if (now - last_ui_retry_time_ < kUiRetrySec) {
return;
}
if (state == RemoteUiState::kOpen) {
ui_reject_count_ = 0;
if (role_ != SessionRole::kControlling) {
SetRole(SessionRole::kControlling); // The claim succeeded.
}
} else if (state == RemoteUiState::kClosedOrError) {
last_ui_retry_time_ = now;
// A kWsCloseControllerTaken close while kControlling means another page
// took the slot (Steal Control): settle instantly. A rejected claim
// (kClaiming) retries a few times first, because a reloading controller
// briefly races its own slot.
const bool ousted = close_code == kWsCloseControllerTaken &&
role_ == SessionRole::kControlling;
if (ousted || (close_code == kWsCloseControllerTaken &&
++ui_reject_count_ >= kMaxUiRejects)) {
LOG(Info, "Controller slot taken; spectating");
SetRole(SessionRole::kSpectating);
// Also drops the last received UI frame: a page forced out of the
// controller slot must not keep showing a frozen Studio UI.
callbacks_.ShutdownRemoteUi();
} else {
LOG(Info, "UI WebSocket closed; reconnecting...");
callbacks_.ConnectRemoteUi();
remote_ui_state_ = RemoteUiState::kConnecting; // As on the grant path.
}
}
}
void Session::HandleMessage(const uint8_t* data, uint32_t num_bytes) {
last_message_time_ = emscripten_get_now() / 1000.0;
if (reload_pending_ || !callbacks_.ReadyForPayload()) {
return;
}
bytes_accum_ += num_bytes;
StatePayloadView view;
if (!ParseStatePayload(data, num_bytes, &view)) {
LOG(Error, "Malformed state payload (%u bytes); dropping", num_bytes);
return;
}
if (!model_crc32_.has_value()) {
model_crc32_ = view.model_crc32;
} else if (view.model_crc32 != *model_crc32_) {
LOG(Info, "Model changed on the Python side (ident %u -> %u); reloading",
*model_crc32_, view.model_crc32);
reload_pending_ = true;
EM_ASM({ setTimeout(function() { location.reload(); }, 0); });
return;
}
callbacks_.OnPayload(view);
}
void Session::RequestControl() { SendText(kMsgRequestControl); }
void Session::LeaveQueue() { SendText(kMsgLeaveQueue); }
void Session::StealControl() { SendText(kMsgForceControl); }
void Session::ReleaseControl() {
// Become a spectator; the server grants the slot down the queue.
callbacks_.ShutdownRemoteUi();
SetRole(SessionRole::kSpectating);
}
void Session::SetCameraMode(int mode) { callbacks_.SetCameraMode(mode); }
void Session::SetMaxSpectators(int count) {
char msg[48];
snprintf(msg, sizeof(msg), "%s%d", kMsgMaxSpectatorsPrefix, count);
SendText(msg);
}
} // namespace mujoco::studio
@@ -0,0 +1,249 @@
// 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.
// The session: this page's relationship with the Python-side viewer.
//
// Owns the /state WebSocket (simulation payloads in, control messages out),
// the session wire protocol (roster, grants, heartbeats, acks), the role
// state machine (claiming -> controlling / spectating), and the
// model-change / page-reload / close-code policies. Everything outside the
// session goes through the Callbacks interface: applying a payload to the
// scene, and driving the remote UI stream on role transitions. The session
// also implements SessionActions, so the role window's intents land here
// directly.
#ifndef MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_SESSION_H_
#define MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_SESSION_H_
#include <emscripten/websocket.h>
#include <cstdint>
#include <optional>
#include <string>
#include "state_payload.h"
namespace mujoco::studio {
// Our custom WebSocket close codes in range 4xxx. The client shows a notice
// and retries slowly.
constexpr int kWsCloseControllerTaken = 4001; // /ui: another browser controls.
constexpr int kWsCloseSessionFull = 4002; // /state: spectator limit hit.
constexpr int kWsCloseInactive = 4003; // /state: hidden tab kicked.
constexpr int kWsCloseNotController = 4004; // /drop: only controller may
// load models.
// The page's role in the collaborative session. Every page starts by
// claiming the controller slot; the claim either succeeds (kControlling)
// or the page settles into spectating. A control grant puts a spectator
// back into kClaiming while it reconnects to /ui.
enum class SessionRole {
kClaiming = 0, // /ui claim in flight; the role is not yet resolved.
kControlling, // This page holds the open /ui connection.
kSpectating, // Another page controls; scene + local role window only.
};
// Read-only snapshot of the session, passed to the local UI each frame.
struct SessionView {
SessionRole role = SessionRole::kClaiming;
int viewers = 0;
int queue_pos = 0; // 1-based position in the control queue; 0 = unqueued.
int queue_len = 0;
int max_spectators = 0;
uint64_t gui_bytes_per_sec = 0;
uint64_t sim_bytes_per_sec = 0;
bool have_remote_frame = false;
int camera_mode = 0; // [SpectatorCamMode].
};
// User intent reported by the role window. Session implements this; the
// interface is the complete list of effects the local UI can cause.
class SessionActions {
public:
virtual ~SessionActions() = default;
virtual void RequestControl() = 0;
virtual void LeaveQueue() = 0;
virtual void StealControl() = 0;
virtual void ReleaseControl() = 0;
virtual void SetCameraMode(int mode) = 0; // mode is [SpectatorCamMode].
virtual void SetMaxSpectators(int count) = 0; // Already clamped by the UI.
};
// The roster: the server's membership broadcast, sent as a text frame on
// /state whenever the session changes (a viewer joins or leaves, queues
// for control, or control moves). It tells this page how many viewers are
// connected, which role the server currently assigns it, and where it
// stands in the control queue.
struct Roster {
int viewers = 0;
bool spectator = false; // The server's view: true = not the controller.
int queue_pos = 0; // 1-based position in the control queue; 0 = unqueued.
int queue_len = 0;
int max_spectators = 8; // Runtime spectator limit.
};
// Parses a roster line; returns false when text is not a roster.
bool ParseRoster(const char* text, Roster* roster);
// The remote UI stream's connection state, reported to the role state
// machine by the app once per frame.
enum class RemoteUiState {
kNoSocket = 0, // No connection attempt exists.
kConnecting, // In flight (or closing); the machine waits.
kOpen, // The claim succeeded: this page controls.
kClosedOrError, // Rejected or dropped; the machine retries or settles.
};
class Session : public SessionActions {
public:
// Everything the session needs from the rest of the application.
class Callbacks {
public:
virtual ~Callbacks() = default;
// Payloads are dropped until this returns true (model loaded).
virtual bool ReadyForPayload() = 0;
// Applies a parsed payload to the application.
virtual void OnPayload(const StatePayloadView& view) = 0;
// Role transition: claim the controller slot.
virtual void ConnectRemoteUi() = 0;
// Role transition: drop the stream when spectating
virtual void ShutdownRemoteUi() = 0;
// Spectator camera mode change.
virtual void SetCameraMode(int mode) = 0;
};
explicit Session(Callbacks& callbacks) : callbacks_(callbacks) {}
void Connect(const std::string& url);
// Records the CRC32 of the model this page actually loaded.
// Must match zlib.crc32 (used to compute model_crc32 in web_viewer.py).
void SetModelCrc32(uint32_t crc) { model_crc32_ = crc; }
// True while a connect attempt exists; used to pace reconnects.
// emscripten_websocket_new returns a handle immediately, so this is NOT the
// same as Connected().
bool HasSocket() const { return socket_ != 0; }
// True only while the WebSocket is actually open.
bool Connected() const { return connected_; }
// True once a payload with a new model has scheduled a page reload; all
// traffic is dropped from then on.
bool ReloadPending() const { return reload_pending_; }
// The close code from the server deliberately ending this connection (codes
// 4000-4999, e.g. kWsCloseSessionFull), else 0. Such conditions are transient
// (a slot frees up, the user returns to the tab), so the page shows a notice
// and retries slowly; the code clears when a connection opens again.
int ServerCloseCode() const { return server_close_code_; }
// Returns the bytes received since the last call and resets the counter.
uint64_t ConsumeByteCount() {
uint64_t bytes = bytes_accum_;
bytes_accum_ = 0;
return bytes;
}
// Wall-clock seconds of the last received message, or 0 before the first one.
// Payloads stream at ~60Hz while the Python side is alive, so staleness here
// means the server is gone, even if the socket still looks open (a suspended
// process keeps its sockets established).
double LastMessageTime() const { return last_message_time_; }
SessionRole Role() const { return role_; }
// Fills the role and roster fields of the view.
void FillView(SessionView* view) const;
// Periodic session upkeep (the ~30s liveness heartbeat); call once per frame.
void Update();
// Feeds the role state machine the remote UI stream's connection state; call
// once per frame. Owns claim retry pacing, promotion to kControlling when a
// claim opens, instant settling when an open stream closes with
// kWsCloseControllerTaken (ousted by Steal Control), and the retries then
// settle rule for rejected claims.
void HandleRemoteUiState(RemoteUiState state, int close_code);
// Parses one WebSocket message and applies the model-change/reload policy.
void HandleMessage(const uint8_t* data, uint32_t num_bytes);
// SessionActions (used to implement the role window UI).
void RequestControl() override;
void LeaveQueue() override;
void StealControl() override;
void ReleaseControl() override;
void SetCameraMode(int mode) override;
void SetMaxSpectators(int count) override;
private:
// Detaches callbacks and frees socket_ (if any), resetting to disconnected.
void CloseSocket();
// Sends a session message (control requests, acks, activity reports) to the
// server as a text frame. Dropped silently while not connected.
void SendText(const char* text);
// Updates the role and mirrors it into JS (Module.isSpectator), which gates
// controller-only page behavior (model drag-and-drop upload).
void SetRole(SessionRole role);
// Routes a session text frame: roster updates, control grants.
void OnSessionText(const char* text);
static EM_BOOL OnWsMessage(int event_type,
const EmscriptenWebSocketMessageEvent* event,
void* user_data);
static EM_BOOL OnWsOpen(int event_type,
const EmscriptenWebSocketOpenEvent* event,
void* user_data);
static EM_BOOL OnWsError(int event_type,
const EmscriptenWebSocketErrorEvent* event,
void* user_data);
static EM_BOOL OnWsClose(int event_type,
const EmscriptenWebSocketCloseEvent* event,
void* user_data);
Callbacks& callbacks_;
EMSCRIPTEN_WEBSOCKET_T socket_ = 0;
bool connected_ = false;
// CRC32 of the model this page loaded. When the payload's crc changes, the
// Python side has swapped models so reload the page; this refetches
// /model.mjb and reconnects everything.
std::optional<uint32_t> model_crc32_;
bool reload_pending_ = false;
int server_close_code_ = 0;
uint64_t bytes_accum_ = 0;
double last_message_time_ = 0;
// Role state machine and roster.
SessionRole role_ = SessionRole::kClaiming;
Roster roster_;
// Consecutive rejected /ui claims; the page eventually stops claiming and
// settles into spectating.
int ui_reject_count_ = 0;
RemoteUiState remote_ui_state_ = RemoteUiState::kNoSocket;
double last_ui_retry_time_ = 0;
double last_heartbeat_time_ = 0;
};
} // namespace mujoco::studio
#endif // MUJOCO_PYTHON_EXPERIMENTAL_STUDIO_WEB_WEB_CLIENT_SESSION_H_
+25 -9
View File
@@ -44,20 +44,38 @@ inline float GetStableAvailWidth() {
// Walk up past child windows to the actual panel that owns the scrollbar.
ImGuiWindow* scroll_owner = window;
while (scroll_owner &&
(scroll_owner->Flags & ImGuiWindowFlags_ChildWindow) &&
while (scroll_owner && (scroll_owner->Flags & ImGuiWindowFlags_ChildWindow) &&
scroll_owner->ParentWindow) {
scroll_owner = scroll_owner->ParentWindow;
}
if (scroll_owner &&
!(scroll_owner->Flags & ImGuiWindowFlags_NoScrollbar) &&
if (scroll_owner && !(scroll_owner->Flags & ImGuiWindowFlags_NoScrollbar) &&
!scroll_owner->ScrollbarY) {
avail_x -= ImGui::GetStyle().ScrollbarSize;
}
return avail_x;
}
// Draws one line of text horizontally centered in the current window.
inline void CenteredLine(const char* text, const ImVec4* color = nullptr) {
ImGui::SetCursorPosX(ImMax(
0.0f, (ImGui::GetWindowWidth() - ImGui::CalcTextSize(text).x) * 0.5f));
if (color != nullptr) {
ImGui::TextColored(*color, "%s", text);
} else {
ImGui::TextUnformatted(text);
}
}
// Large centered banner text.
inline void CenteredBanner(const char* text, const ImVec4& color) {
ImGui::SetWindowFontScale(1.6f);
ImGui::SetCursorPosX(ImMax(
0.0f, (ImGui::GetWindowWidth() - ImGui::CalcTextSize(text).x) * 0.5f));
ImGui::TextColored(color, "%s", text);
ImGui::SetWindowFontScale(1.0f);
}
// FontAwesome icon codes.
static constexpr const char ICON_FA_ADJUST[] = "\xEF\x81\x82";
static constexpr const char ICON_FA_ARROWS[] = "\xEF\x81\x87";
@@ -160,9 +178,7 @@ struct ScopedStyle {
return *this;
}
ScopedStyle& Font(ScopedFont font) {
return Font(static_cast<int>(font));
}
ScopedStyle& Font(ScopedFont font) { return Font(static_cast<int>(font)); }
ScopedStyle& Color(ImGuiCol col, ImColor color) {
ImGui::PushStyleColor(col, (ImU32)color);
@@ -648,8 +664,8 @@ inline bool ImGui_ColorButtonEx(const char* label, bool active, ImColor color,
// Draw border.
if (s.FrameBorderSize > 0) {
dl->AddRect(pos, max, ImGui::GetColorU32(ImGuiCol_Border),
s.FrameRounding, corners, s.FrameBorderSize);
dl->AddRect(pos, max, ImGui::GetColorU32(ImGuiCol_Border), s.FrameRounding,
corners, s.FrameBorderSize);
}
// Draw label centered.