First-pass implementation of web-based studio app.
PiperOrigin-RevId: 843685213 Change-Id: I2642a21d5573ef548eece0eb9a2c634e4881eec5
This commit is contained in:
committed by
Copybara-Service
parent
157d261701
commit
3da421afaa
@@ -56,7 +56,7 @@
|
|||||||
namespace mujoco::studio {
|
namespace mujoco::studio {
|
||||||
|
|
||||||
static constexpr platform::Window::Config kWindowConfig = {
|
static constexpr platform::Window::Config kWindowConfig = {
|
||||||
#ifdef EMSCRIPTEN
|
#if defined(__EMSCRIPTEN__)
|
||||||
.render_config = platform::Window::RenderConfig::kFilamentWebGL,
|
.render_config = platform::Window::RenderConfig::kFilamentWebGL,
|
||||||
#elif defined(USE_FILAMENT_VULKAN)
|
#elif defined(USE_FILAMENT_VULKAN)
|
||||||
.render_config = platform::Window::RenderConfig::kFilamentVulkan,
|
.render_config = platform::Window::RenderConfig::kFilamentVulkan,
|
||||||
@@ -195,34 +195,36 @@ void App::LoadModel(std::string data, ContentType type) {
|
|||||||
// Delete the existing mjModel and mjData.
|
// Delete the existing mjModel and mjData.
|
||||||
ClearModel();
|
ClearModel();
|
||||||
|
|
||||||
char err[1000] = "";
|
if (!data.empty()) {
|
||||||
if (type == ContentType::kFilepath) {
|
char err[1000] = "";
|
||||||
// Store the file path as the model name. Note that we use this model name
|
if (type == ContentType::kFilepath) {
|
||||||
// to perform reload operations.
|
// Store the file path as the model name. Note that we use this model name
|
||||||
model_name_ = std::move(data);
|
// to perform reload operations.
|
||||||
if (model_name_.ends_with(".mjb")) {
|
model_name_ = std::move(data);
|
||||||
model_ = mj_loadModel(model_name_.c_str(), 0);
|
if (model_name_.ends_with(".mjb")) {
|
||||||
} else if (model_name_.ends_with(".xml")) {
|
model_ = mj_loadModel(model_name_.c_str(), 0);
|
||||||
spec_ = mj_parseXML(model_name_.c_str(), nullptr, err, sizeof(err));
|
} else if (model_name_.ends_with(".xml")) {
|
||||||
|
spec_ = mj_parseXML(model_name_.c_str(), nullptr, err, sizeof(err));
|
||||||
|
if (spec_ && err[0] == 0) {
|
||||||
|
model_ = mj_compile(spec_, nullptr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error_ = "Unknown model file type; expected .mjb or .xml.";
|
||||||
|
}
|
||||||
|
} else if (type == ContentType::kModelXml) {
|
||||||
|
model_name_ = "[xml]";
|
||||||
|
spec_ = mj_parseXMLString(data.c_str(), nullptr, err, sizeof(err));
|
||||||
if (spec_ && err[0] == 0) {
|
if (spec_ && err[0] == 0) {
|
||||||
model_ = mj_compile(spec_, nullptr);
|
model_ = mj_compile(spec_, nullptr);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (type == ContentType::kModelMjb) {
|
||||||
error_ = "Unknown model file type; expected .mjb or .xml.";
|
model_name_ = "[mjb]";
|
||||||
|
model_ = mj_loadModelBuffer(data.data(), data.size());
|
||||||
}
|
}
|
||||||
} else if (type == ContentType::kModelXml) {
|
|
||||||
model_name_ = "[xml]";
|
|
||||||
spec_ = mj_parseXMLString(data.c_str(), nullptr, err, sizeof(err));
|
|
||||||
if (spec_ && err[0] == 0) {
|
|
||||||
model_ = mj_compile(spec_, nullptr);
|
|
||||||
}
|
|
||||||
} else if (type == ContentType::kModelMjb) {
|
|
||||||
model_name_ = "[mjb]";
|
|
||||||
model_ = mj_loadModelBuffer(data.data(), data.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err[0]) {
|
if (err[0]) {
|
||||||
error_ = err;
|
error_ = err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no mjModel was loaded, load an empty mjModel.
|
// If no mjModel was loaded, load an empty mjModel.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>MuJoCo Studio!</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; overflow: hidden">
|
||||||
|
<div style="position: absolute; top: 3px; right: 3px; z-index: 1;">
|
||||||
|
<button id="uploadButton">Upload Model</button>
|
||||||
|
<input type="file" id="fileInput" accept=".xml,.mjb" style="display: none;">
|
||||||
|
</div>
|
||||||
|
<canvas
|
||||||
|
class="emscripten"
|
||||||
|
id="canvas"
|
||||||
|
oncontextmenu="event.preventDefault()"
|
||||||
|
style="width: 100vw; height: 100vh; display: block"
|
||||||
|
></canvas>
|
||||||
|
<script>
|
||||||
|
var Module = {
|
||||||
|
preRun: [],
|
||||||
|
postRun: [],
|
||||||
|
locateFile: function(path) {
|
||||||
|
const baseURL = window.location.origin + window.location.pathname.substring(0, window.location.pathname.lastIndexOf("/"));
|
||||||
|
return baseURL + "/bin/" + path;
|
||||||
|
},
|
||||||
|
print: console.log,
|
||||||
|
printErr: text => {
|
||||||
|
console.error(text + "\n" + new Error().stack);
|
||||||
|
},
|
||||||
|
canvas: (() => {
|
||||||
|
const canvas = document.getElementById("canvas");
|
||||||
|
// As a default initial behavior, pop up an alert when webgl context is lost. To make your
|
||||||
|
// application robust, you may want to override this behavior before shipping!
|
||||||
|
// See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2
|
||||||
|
canvas.addEventListener(
|
||||||
|
"webglcontextlost",
|
||||||
|
e => {
|
||||||
|
alert("WebGL context lost. You will need to reload the page.");
|
||||||
|
e.preventDefault();
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return canvas;
|
||||||
|
})(),
|
||||||
|
setStatus(text) {},
|
||||||
|
totalDependencies: 0,
|
||||||
|
monitorRunDependencies(left) {},
|
||||||
|
onRuntimeInitialized: () => {
|
||||||
|
// Define assets to prefetch. These paths are relative to the wasm_binary/ directory.
|
||||||
|
const assetsToPrefetch = [
|
||||||
|
"ibl.ktx",
|
||||||
|
"pbr.filamat",
|
||||||
|
"pbr_packed.filamat",
|
||||||
|
"phong_2d.filamat",
|
||||||
|
"phong_2d_uv.filamat",
|
||||||
|
"phong_color.filamat",
|
||||||
|
"phong_cube.filamat",
|
||||||
|
"unlit_line.filamat",
|
||||||
|
"unlit_ui.filamat",
|
||||||
|
"phong_2d_fade.filamat",
|
||||||
|
"phong_2d_uv_fade.filamat",
|
||||||
|
"phong_color_fade.filamat",
|
||||||
|
"phong_cube_fade.filamat",
|
||||||
|
"unlit_depth.filamat",
|
||||||
|
"unlit_segmentation.filamat",
|
||||||
|
"OpenSans-Regular.ttf",
|
||||||
|
"fontawesome-webfont.ttf",
|
||||||
|
];
|
||||||
|
|
||||||
|
const assetPromises = assetsToPrefetch.map(async (relativePath) => {
|
||||||
|
const assetUrl = Module.locateFile(relativePath);
|
||||||
|
try {
|
||||||
|
const response = await fetch(assetUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${assetUrl}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const filename = relativePath.substring(relativePath.lastIndexOf('/') + 1);
|
||||||
|
Module.registerAsset(filename, new Uint8Array(buffer));
|
||||||
|
console.log(`Registered asset: ${filename}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error prefetching asset ${assetUrl}:`, error);
|
||||||
|
throw error; // Re-throw to be caught by Promise.all
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(assetPromises)
|
||||||
|
.then(() => {
|
||||||
|
try {
|
||||||
|
Module.init();
|
||||||
|
requestAnimationFrame(Module.animate);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize app.', error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to prefetch one or more assets.', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
animate: () => {
|
||||||
|
try {
|
||||||
|
Module.renderFrame();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update error:', error);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(Module.animate);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Ensure the canvas is resized when the window is resized.
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
Module.canvas.style.width = window.innerWidth + "px";
|
||||||
|
Module.canvas.style.height = window.innerHeight + "px";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const uploadButton = document.getElementById('uploadButton');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
|
||||||
|
uploadButton.addEventListener('click', () => {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const buffer = e.target.result;
|
||||||
|
try {
|
||||||
|
if (file.name.endsWith('.mjb')) {
|
||||||
|
Module.loadMjb(buffer);
|
||||||
|
} else if (file.name.endsWith('.xml')) {
|
||||||
|
Module.loadXml(buffer);
|
||||||
|
} else {
|
||||||
|
console.error('Unsupported file type:', file.name);
|
||||||
|
}
|
||||||
|
console.log('Model loaded from file:', file.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load model from file:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = (e) => {
|
||||||
|
console.error('Error reading file:', e);
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file); // Reads as text, suitable for XML.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script async src="/bin/wasm.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Copyright 2025 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
|
||||||
|
//
|
||||||
|
// http://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.
|
||||||
|
|
||||||
|
// Main entry point for the Filament-based MuJoCo web app.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <emscripten.h>
|
||||||
|
#include <emscripten/bind.h>
|
||||||
|
#include <emscripten/val.h>
|
||||||
|
|
||||||
|
#include "experimental/studio/app.h"
|
||||||
|
|
||||||
|
// Global app instance. Lifetime is controlled by Init/Deinit calls which are
|
||||||
|
// triggered by Javascript.
|
||||||
|
mujoco::studio::App* g_app = nullptr;
|
||||||
|
|
||||||
|
// Static registry of assets that are loaded in JSON before the main App is
|
||||||
|
// initialized.
|
||||||
|
class AssetRegistry {
|
||||||
|
public:
|
||||||
|
// Returns the singleton instance of the registry.
|
||||||
|
static AssetRegistry& Instance() {
|
||||||
|
static AssetRegistry instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registers asset contents with the given filename.
|
||||||
|
void RegisterAsset(std::string filename, std::string contents) {
|
||||||
|
assets_[filename] = std::move(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the contents of the given asset by name.
|
||||||
|
std::vector<std::byte> LoadAsset(std::string_view filename) {
|
||||||
|
if (auto it = assets_.find(std::string(filename)); it != assets_.end()) {
|
||||||
|
const std::byte* begin = reinterpret_cast<const std::byte*>(it->second.data());
|
||||||
|
const std::byte* end = begin + it->second.size();
|
||||||
|
return std::vector<std::byte>(begin, end);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unordered_map<std::string, std::string> assets_;
|
||||||
|
};
|
||||||
|
|
||||||
|
static std::vector<std::byte> LoadAsset(std::string_view filename) {
|
||||||
|
return AssetRegistry::Instance().LoadAsset(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Javascript-facing function to register an asset.
|
||||||
|
void RegisterAsset(std::string filename, std::string contents) {
|
||||||
|
AssetRegistry::Instance().RegisterAsset(std::move(filename),
|
||||||
|
std::move(contents));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Javascript-facing function to initialize the app.
|
||||||
|
void Init() {
|
||||||
|
// Note: dimensions do not matter as window will be resized to fit canvas.
|
||||||
|
const int width = 100;
|
||||||
|
const int height = 100;
|
||||||
|
const std::string ini_path = "";
|
||||||
|
g_app = new mujoco::studio::App(width, height, ini_path, LoadAsset);
|
||||||
|
g_app->LoadModel("", mujoco::studio::App::ContentType::kModelXml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Javascript-facing function to load a model from a MJB file.
|
||||||
|
void LoadMjb(const std::string& src) {
|
||||||
|
if (g_app) {
|
||||||
|
g_app->LoadModel(src, mujoco::studio::App::ContentType::kModelMjb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Javascript-facing function to load a model from an XML file.
|
||||||
|
void LoadXml(const std::string& src) {
|
||||||
|
if (g_app) {
|
||||||
|
g_app->LoadModel(src, mujoco::studio::App::ContentType::kModelXml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Javascript-facing function to render a single frame.
|
||||||
|
void RenderFrame() {
|
||||||
|
if (g_app) {
|
||||||
|
if (g_app->Update()) {
|
||||||
|
g_app->BuildGui();
|
||||||
|
g_app->Render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Javascript-facing function to deinitialize the app.
|
||||||
|
void Deinit() {
|
||||||
|
delete g_app;
|
||||||
|
g_app = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
EMSCRIPTEN_BINDINGS(studio_bindings) {
|
||||||
|
emscripten::function("registerAsset", &RegisterAsset);
|
||||||
|
emscripten::function("init", &Init);
|
||||||
|
emscripten::function("loadMjb", &LoadMjb);
|
||||||
|
emscripten::function("loadXml", &LoadXml);
|
||||||
|
emscripten::function("renderFrame", &RenderFrame);
|
||||||
|
emscripten::function("deinit", &Deinit);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user