Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 151 additions & 13 deletions web/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,103 @@ async function fetchModelFile(path) {
const fetchModelBytes = async (path) =>
new Uint8Array(await (await fetchModelFile(path)).arrayBuffer());

function asArray(value) {
if (!value) return value;
if (typeof value.getView === "function") return value.getView();
return value;
}

function createCheckerTexture(repeatX = 5, repeatY = 5) {
const size = 256;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const cells = 8;
const cell = size / cells;
for (let y = 0; y < cells; y += 1) {
for (let x = 0; x < cells; x += 1) {
const dark = (x + y) % 2 === 0;
ctx.fillStyle = dark ? "#334455" : "#1a2833";
ctx.fillRect(x * cell, y * cell, cell, cell);
}
}
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(repeatX, repeatY);
texture.colorSpace = THREE.NoColorSpace;
texture.anisotropy = 4;
return texture;
}

function materialHasTexture(model, matId) {
// mat_texid is nmat × mjNTEXROLE; any non-negative role means textured.
const texIds = asArray(model.mat_texid);
if (!texIds || matId < 0) return false;
const nrole = Math.max(
1,
Math.floor(texIds.length / Math.max(model.nmat, 1)),
);
const base = matId * nrole;
for (let r = 0; r < nrole; r += 1) {
if (texIds[base + r] >= 0) return true;
}
return false;
}

function resolveGeomAppearance(model, geomIndex, textureCache) {
const geomRgba = asArray(model.geom_rgba);
const geomMatid = asArray(model.geom_matid);
let rgba = [
geomRgba[geomIndex * 4],
geomRgba[geomIndex * 4 + 1],
geomRgba[geomIndex * 4 + 2],
geomRgba[geomIndex * 4 + 3],
];

// Defaults match MuJoCo's Phong-like material model (not PBR metalness).
let shininess = 50;
let specular = 0.5;
let emission = 0;
let map = null;
const matId = geomMatid?.[geomIndex] ?? -1;

if (matId >= 0) {
// MuJoCo applies material rgba over the geom default (often 0.5 gray).
const matRgba = asArray(model.mat_rgba);
rgba = [
matRgba[matId * 4],
matRgba[matId * 4 + 1],
matRgba[matId * 4 + 2],
matRgba[matId * 4 + 3],
];

const matShininess = asArray(model.mat_shininess)?.[matId] ?? 0.5;
const matSpecular = asArray(model.mat_specular)?.[matId] ?? 0.5;
const matEmission = asArray(model.mat_emission)?.[matId] ?? 0;
shininess = Math.max(1, matShininess * 100);
specular = matSpecular;
emission = matEmission;

if (materialHasTexture(model, matId)) {
const texRepeat = asArray(model.mat_texrepeat);
const repeatX = texRepeat?.[matId * 2] ?? 1;
const repeatY = texRepeat?.[matId * 2 + 1] ?? 1;
const key = `${Math.max(repeatX, 1)},${Math.max(repeatY, 1)}`;
if (!textureCache.has(key)) {
textureCache.set(
key,
createCheckerTexture(Math.max(repeatX, 1), Math.max(repeatY, 1)),
);
}
map = textureCache.get(key);
}
}

return { rgba, shininess, specular, emission, map };
}

class App {
constructor() {
this.mjvPerturb = new mujoco.MjvPerturb();
Expand All @@ -66,6 +163,7 @@ class App {
this.maxGeoms = 2 ** 14;
this.meshes = [];
this.bufferGeometryCache = new Map();
this.checkerTextureCache = new Map();
this.teleop = new TeleopState();
this.held = new Set();
this.lifterHeight = 0;
Expand All @@ -76,6 +174,8 @@ class App {

this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
// MuJoCo material rgba is authored for direct display (not Three's sRGB workflow).
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
document.body.appendChild(this.renderer.domElement);

this.camera = new THREE.PerspectiveCamera(
Expand Down Expand Up @@ -106,6 +206,8 @@ class App {
this.meshes = [];
for (const geom of this.bufferGeometryCache.values()) geom.dispose();
this.bufferGeometryCache.clear();
for (const texture of this.checkerTextureCache.values()) texture.dispose();
this.checkerTextureCache.clear();
if (this.markers) {
for (const side of SIDES) this.scene.remove(this.markers[side]);
this.markers = null;
Expand Down Expand Up @@ -311,6 +413,54 @@ class App {
return geom;
}

applyGeomAppearance(mesh, g) {
const mat = mesh.material;
let rgba = g.rgba;
let shininess = 50;
let specular = 0.5;
let emission = 0;
let map = null;
if (
g.objtype === mujoco.mjtObj.mjOBJ_GEOM.value &&
g.objid >= 0 &&
this.mjModel
) {
const appearance = resolveGeomAppearance(
this.mjModel,
g.objid,
this.checkerTextureCache,
);
rgba = appearance.rgba;
shininess = appearance.shininess;
specular = appearance.specular;
emission = appearance.emission;
map = appearance.map;
}

mat.color.setRGB(rgba[0], rgba[1], rgba[2]);
let opacity = rgba[3];
let transparent = opacity < 0.999;
let depthWrite = true;
if (
g.objtype === mujoco.mjtObj.mjOBJ_GEOM.value &&
this.transparentGeomIds.has(g.objid)
) {
opacity = 0.15;
transparent = true;
depthWrite = false;
}
mat.opacity = opacity;
mat.transparent = transparent;
mat.depthWrite = depthWrite;
mat.shininess = shininess;
mat.specular.setRGB(specular, specular, specular);
mat.emissive.setRGB(emission, emission, emission);
if (mat.map !== map) {
mat.map = map;
mat.needsUpdate = true;
}
}

update(dt) {
this.controls.update();
if (!this.mjModel) return; // scene is loading
Expand Down Expand Up @@ -354,19 +504,7 @@ class App {
this.scene.add(mesh);
}
mesh.visible = true;
mesh.material.color.setRGB(g.rgba[0], g.rgba[1], g.rgba[2]);
mesh.material.opacity = g.rgba[3];
mesh.material.transparent = g.rgba[3] < 1;
if (
g.objtype === mujoco.mjtObj.mjOBJ_GEOM.value &&
this.transparentGeomIds.has(g.objid)
) {
mesh.material.opacity = 0.15;
mesh.material.transparent = true;
mesh.material.depthWrite = false;
} else {
mesh.material.depthWrite = true;
}
this.applyGeomAppearance(mesh, g);
mesh.matrixAutoUpdate = false;
mesh.matrix.set(
g.mat[0],
Expand Down