Skip to content

Commit 3da421a

Browse files
haroonqcopybara-github
authored andcommitted
First-pass implementation of web-based studio app.
PiperOrigin-RevId: 843685213 Change-Id: I2642a21d5573ef548eece0eb9a2c634e4881eec5
1 parent 157d261 commit 3da421a

File tree

3 files changed

+300
-24
lines changed

3 files changed

+300
-24
lines changed

src/experimental/studio/app.cc

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
namespace mujoco::studio {
5757

5858
static constexpr platform::Window::Config kWindowConfig = {
59-
#ifdef EMSCRIPTEN
59+
#if defined(__EMSCRIPTEN__)
6060
.render_config = platform::Window::RenderConfig::kFilamentWebGL,
6161
#elif defined(USE_FILAMENT_VULKAN)
6262
.render_config = platform::Window::RenderConfig::kFilamentVulkan,
@@ -195,34 +195,36 @@ void App::LoadModel(std::string data, ContentType type) {
195195
// Delete the existing mjModel and mjData.
196196
ClearModel();
197197

198-
char err[1000] = "";
199-
if (type == ContentType::kFilepath) {
200-
// Store the file path as the model name. Note that we use this model name
201-
// to perform reload operations.
202-
model_name_ = std::move(data);
203-
if (model_name_.ends_with(".mjb")) {
204-
model_ = mj_loadModel(model_name_.c_str(), 0);
205-
} else if (model_name_.ends_with(".xml")) {
206-
spec_ = mj_parseXML(model_name_.c_str(), nullptr, err, sizeof(err));
198+
if (!data.empty()) {
199+
char err[1000] = "";
200+
if (type == ContentType::kFilepath) {
201+
// Store the file path as the model name. Note that we use this model name
202+
// to perform reload operations.
203+
model_name_ = std::move(data);
204+
if (model_name_.ends_with(".mjb")) {
205+
model_ = mj_loadModel(model_name_.c_str(), 0);
206+
} else if (model_name_.ends_with(".xml")) {
207+
spec_ = mj_parseXML(model_name_.c_str(), nullptr, err, sizeof(err));
208+
if (spec_ && err[0] == 0) {
209+
model_ = mj_compile(spec_, nullptr);
210+
}
211+
} else {
212+
error_ = "Unknown model file type; expected .mjb or .xml.";
213+
}
214+
} else if (type == ContentType::kModelXml) {
215+
model_name_ = "[xml]";
216+
spec_ = mj_parseXMLString(data.c_str(), nullptr, err, sizeof(err));
207217
if (spec_ && err[0] == 0) {
208218
model_ = mj_compile(spec_, nullptr);
209219
}
210-
} else {
211-
error_ = "Unknown model file type; expected .mjb or .xml.";
220+
} else if (type == ContentType::kModelMjb) {
221+
model_name_ = "[mjb]";
222+
model_ = mj_loadModelBuffer(data.data(), data.size());
212223
}
213-
} else if (type == ContentType::kModelXml) {
214-
model_name_ = "[xml]";
215-
spec_ = mj_parseXMLString(data.c_str(), nullptr, err, sizeof(err));
216-
if (spec_ && err[0] == 0) {
217-
model_ = mj_compile(spec_, nullptr);
218-
}
219-
} else if (type == ContentType::kModelMjb) {
220-
model_name_ = "[mjb]";
221-
model_ = mj_loadModelBuffer(data.data(), data.size());
222-
}
223224

224-
if (err[0]) {
225-
error_ = err;
225+
if (err[0]) {
226+
error_ = err;
227+
}
226228
}
227229

228230
// If no mjModel was loaded, load an empty mjModel.

src/experimental/studio/index.html

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>MuJoCo Studio!</title>
6+
</head>
7+
<body style="margin: 0; overflow: hidden">
8+
<div style="position: absolute; top: 3px; right: 3px; z-index: 1;">
9+
<button id="uploadButton">Upload Model</button>
10+
<input type="file" id="fileInput" accept=".xml,.mjb" style="display: none;">
11+
</div>
12+
<canvas
13+
class="emscripten"
14+
id="canvas"
15+
oncontextmenu="event.preventDefault()"
16+
style="width: 100vw; height: 100vh; display: block"
17+
></canvas>
18+
<script>
19+
var Module = {
20+
preRun: [],
21+
postRun: [],
22+
locateFile: function(path) {
23+
const baseURL = window.location.origin + window.location.pathname.substring(0, window.location.pathname.lastIndexOf("/"));
24+
return baseURL + "/bin/" + path;
25+
},
26+
print: console.log,
27+
printErr: text => {
28+
console.error(text + "\n" + new Error().stack);
29+
},
30+
canvas: (() => {
31+
const canvas = document.getElementById("canvas");
32+
// As a default initial behavior, pop up an alert when webgl context is lost. To make your
33+
// application robust, you may want to override this behavior before shipping!
34+
// See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
35+
canvas.addEventListener(
36+
"webglcontextlost",
37+
e => {
38+
alert("WebGL context lost. You will need to reload the page.");
39+
e.preventDefault();
40+
},
41+
false,
42+
);
43+
return canvas;
44+
})(),
45+
setStatus(text) {},
46+
totalDependencies: 0,
47+
monitorRunDependencies(left) {},
48+
onRuntimeInitialized: () => {
49+
// Define assets to prefetch. These paths are relative to the wasm_binary/ directory.
50+
const assetsToPrefetch = [
51+
"ibl.ktx",
52+
"pbr.filamat",
53+
"pbr_packed.filamat",
54+
"phong_2d.filamat",
55+
"phong_2d_uv.filamat",
56+
"phong_color.filamat",
57+
"phong_cube.filamat",
58+
"unlit_line.filamat",
59+
"unlit_ui.filamat",
60+
"phong_2d_fade.filamat",
61+
"phong_2d_uv_fade.filamat",
62+
"phong_color_fade.filamat",
63+
"phong_cube_fade.filamat",
64+
"unlit_depth.filamat",
65+
"unlit_segmentation.filamat",
66+
"OpenSans-Regular.ttf",
67+
"fontawesome-webfont.ttf",
68+
];
69+
70+
const assetPromises = assetsToPrefetch.map(async (relativePath) => {
71+
const assetUrl = Module.locateFile(relativePath);
72+
try {
73+
const response = await fetch(assetUrl);
74+
if (!response.ok) {
75+
throw new Error(`Failed to fetch ${assetUrl}: ${response.statusText}`);
76+
}
77+
const buffer = await response.arrayBuffer();
78+
const filename = relativePath.substring(relativePath.lastIndexOf('/') + 1);
79+
Module.registerAsset(filename, new Uint8Array(buffer));
80+
console.log(`Registered asset: ${filename}`);
81+
} catch (error) {
82+
console.error(`Error prefetching asset ${assetUrl}:`, error);
83+
throw error; // Re-throw to be caught by Promise.all
84+
}
85+
});
86+
87+
Promise.all(assetPromises)
88+
.then(() => {
89+
try {
90+
Module.init();
91+
requestAnimationFrame(Module.animate);
92+
} catch (error) {
93+
console.error('Failed to initialize app.', error);
94+
}
95+
})
96+
.catch((error) => {
97+
console.error('Failed to prefetch one or more assets.', error);
98+
});
99+
},
100+
animate: () => {
101+
try {
102+
Module.renderFrame();
103+
} catch (error) {
104+
console.error('Update error:', error);
105+
}
106+
requestAnimationFrame(Module.animate);
107+
},
108+
};
109+
// Ensure the canvas is resized when the window is resized.
110+
window.addEventListener("resize", function () {
111+
Module.canvas.style.width = window.innerWidth + "px";
112+
Module.canvas.style.height = window.innerHeight + "px";
113+
});
114+
115+
document.addEventListener('DOMContentLoaded', () => {
116+
const uploadButton = document.getElementById('uploadButton');
117+
const fileInput = document.getElementById('fileInput');
118+
119+
uploadButton.addEventListener('click', () => {
120+
fileInput.click();
121+
});
122+
123+
fileInput.addEventListener('change', (event) => {
124+
const file = event.target.files[0];
125+
if (!file) {
126+
return;
127+
}
128+
129+
const reader = new FileReader();
130+
reader.onload = (e) => {
131+
const buffer = e.target.result;
132+
try {
133+
if (file.name.endsWith('.mjb')) {
134+
Module.loadMjb(buffer);
135+
} else if (file.name.endsWith('.xml')) {
136+
Module.loadXml(buffer);
137+
} else {
138+
console.error('Unsupported file type:', file.name);
139+
}
140+
console.log('Model loaded from file:', file.name);
141+
} catch (error) {
142+
console.error('Failed to load model from file:', error);
143+
}
144+
};
145+
reader.onerror = (e) => {
146+
console.error('Error reading file:', e);
147+
};
148+
reader.readAsArrayBuffer(file); // Reads as text, suitable for XML.
149+
});
150+
});
151+
</script>
152+
<script async src="/bin/wasm.js"></script>
153+
</body>
154+
</html>

src/experimental/studio/wasm.cc

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright 2025 DeepMind Technologies Limited
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Main entry point for the Filament-based MuJoCo web app.
16+
17+
#include <cstddef>
18+
#include <string>
19+
#include <string_view>
20+
#include <unordered_map>
21+
#include <vector>
22+
#include <utility>
23+
24+
#include <emscripten.h>
25+
#include <emscripten/bind.h>
26+
#include <emscripten/val.h>
27+
28+
#include "experimental/studio/app.h"
29+
30+
// Global app instance. Lifetime is controlled by Init/Deinit calls which are
31+
// triggered by Javascript.
32+
mujoco::studio::App* g_app = nullptr;
33+
34+
// Static registry of assets that are loaded in JSON before the main App is
35+
// initialized.
36+
class AssetRegistry {
37+
public:
38+
// Returns the singleton instance of the registry.
39+
static AssetRegistry& Instance() {
40+
static AssetRegistry instance;
41+
return instance;
42+
}
43+
44+
// Registers asset contents with the given filename.
45+
void RegisterAsset(std::string filename, std::string contents) {
46+
assets_[filename] = std::move(contents);
47+
}
48+
49+
// Returns the contents of the given asset by name.
50+
std::vector<std::byte> LoadAsset(std::string_view filename) {
51+
if (auto it = assets_.find(std::string(filename)); it != assets_.end()) {
52+
const std::byte* begin = reinterpret_cast<const std::byte*>(it->second.data());
53+
const std::byte* end = begin + it->second.size();
54+
return std::vector<std::byte>(begin, end);
55+
}
56+
return {};
57+
}
58+
59+
private:
60+
std::unordered_map<std::string, std::string> assets_;
61+
};
62+
63+
static std::vector<std::byte> LoadAsset(std::string_view filename) {
64+
return AssetRegistry::Instance().LoadAsset(filename);
65+
}
66+
67+
// Javascript-facing function to register an asset.
68+
void RegisterAsset(std::string filename, std::string contents) {
69+
AssetRegistry::Instance().RegisterAsset(std::move(filename),
70+
std::move(contents));
71+
}
72+
73+
// Javascript-facing function to initialize the app.
74+
void Init() {
75+
// Note: dimensions do not matter as window will be resized to fit canvas.
76+
const int width = 100;
77+
const int height = 100;
78+
const std::string ini_path = "";
79+
g_app = new mujoco::studio::App(width, height, ini_path, LoadAsset);
80+
g_app->LoadModel("", mujoco::studio::App::ContentType::kModelXml);
81+
}
82+
83+
// Javascript-facing function to load a model from a MJB file.
84+
void LoadMjb(const std::string& src) {
85+
if (g_app) {
86+
g_app->LoadModel(src, mujoco::studio::App::ContentType::kModelMjb);
87+
}
88+
}
89+
90+
// Javascript-facing function to load a model from an XML file.
91+
void LoadXml(const std::string& src) {
92+
if (g_app) {
93+
g_app->LoadModel(src, mujoco::studio::App::ContentType::kModelXml);
94+
}
95+
}
96+
97+
// Javascript-facing function to render a single frame.
98+
void RenderFrame() {
99+
if (g_app) {
100+
if (g_app->Update()) {
101+
g_app->BuildGui();
102+
g_app->Render();
103+
}
104+
}
105+
}
106+
107+
// Javascript-facing function to deinitialize the app.
108+
void Deinit() {
109+
delete g_app;
110+
g_app = nullptr;
111+
}
112+
113+
EMSCRIPTEN_BINDINGS(studio_bindings) {
114+
emscripten::function("registerAsset", &RegisterAsset);
115+
emscripten::function("init", &Init);
116+
emscripten::function("loadMjb", &LoadMjb);
117+
emscripten::function("loadXml", &LoadXml);
118+
emscripten::function("renderFrame", &RenderFrame);
119+
emscripten::function("deinit", &Deinit);
120+
}

0 commit comments

Comments
 (0)