diff --git a/src/components/heightfield.js b/src/components/heightfield.js
new file mode 100644
index 0000000000000000000000000000000000000000..b1ed8b46bee36c243544476a7f59fb33b575a5fb
--- /dev/null
+++ b/src/components/heightfield.js
@@ -0,0 +1,67 @@
+/* global CANNON */
+AFRAME.registerComponent("heightfield", {
+  init() {
+    this.el.addEventListener("componentinitialized", e => {
+      if (e.detail.name === "static-body") {
+        this.generateAndAddHeightfield(this.el.components["static-body"]);
+      }
+    });
+    this.el.setAttribute("static-body", { shape: "none", mass: 0 });
+  },
+  generateAndAddHeightfield(body) {
+    const mesh = this.el.object3D.getObjectByProperty("type", "Mesh");
+    mesh.geometry.computeBoundingBox();
+    const size = new THREE.Vector3();
+    mesh.geometry.boundingBox.getSize(size);
+
+    const minDistance = 0.25;
+    const resolution = (size.x + size.z) / 2 / minDistance;
+    const distance = Math.max(minDistance, (size.x + size.z) / 2 / resolution);
+
+    const data = [];
+    const down = new THREE.Vector3(0, -1, 0);
+    const position = new THREE.Vector3();
+    const raycaster = new THREE.Raycaster();
+    const intersections = [];
+    const meshPos = new THREE.Vector3();
+    mesh.getWorldPosition(meshPos);
+    const offsetX = -size.x / 2 + meshPos.x;
+    const offsetZ = -size.z / 2 + meshPos.z;
+    let min = Infinity;
+    for (let z = 0; z < resolution; z++) {
+      data[z] = [];
+      for (let x = 0; x < resolution; x++) {
+        position.set(offsetX + x * distance, size.y / 2, offsetZ + z * distance);
+        raycaster.set(position, down);
+        intersections.length = 0;
+        raycaster.intersectObject(mesh, false, intersections);
+        let val;
+        if (intersections.length) {
+          val = -intersections[0].distance + size.y / 2;
+        } else {
+          val = -size.y / 2;
+        }
+        data[z][x] = val;
+        if (val < min) {
+          min = data[z][x];
+        }
+      }
+    }
+    // Cannon doesn't like heightfields with negative heights.
+    for (let z = 0; z < resolution; z++) {
+      for (let x = 0; x < resolution; x++) {
+        data[z][x] -= min;
+      }
+    }
+
+    const orientation = new CANNON.Quaternion();
+    orientation.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2);
+    const rotation = new CANNON.Quaternion();
+    rotation.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), -Math.PI / 2);
+    rotation.mult(orientation, orientation);
+    const offset = new CANNON.Vec3(-size.x / 2, min, -size.z / 2);
+
+    const shape = new CANNON.Heightfield(data, { elementSize: distance });
+    body.addShape(shape, offset, orientation);
+  }
+});
diff --git a/src/components/skybox.js b/src/components/skybox.js
index e5f26b12fe93d006fa15920378223dc1d566243b..08566b88c0388b99d1d4de4db8c79ea9f85e1863 100644
--- a/src/components/skybox.js
+++ b/src/components/skybox.js
@@ -271,6 +271,8 @@ AFRAME.registerComponent("skybox", {
       const z = distance * Math.sin(phi) * Math.cos(theta);
 
       uniforms.sunPosition.value.set(x, y, z).normalize();
+
+      this.sky.scale.set(distance, distance, distance);
     }
   },
 
diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js
index f39748d390bbbd0ab7fe0b17ce45a15bd00125f3..73c7d628ee70e5b1182f6a035a0aeef701488221 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -5,6 +5,9 @@ AFRAME.GLTFModelPlus.registerComponent("quack", "quack");
 AFRAME.GLTFModelPlus.registerComponent("sound", "sound");
 AFRAME.GLTFModelPlus.registerComponent("collision-filter", "collision-filter");
 AFRAME.GLTFModelPlus.registerComponent("css-class", "css-class");
+AFRAME.GLTFModelPlus.registerComponent("interactable", "css-class", (el, componentName) => {
+  el.setAttribute(componentName, "interactable");
+});
 AFRAME.GLTFModelPlus.registerComponent("scene-shadow", "scene-shadow");
 AFRAME.GLTFModelPlus.registerComponent("super-spawner", "super-spawner");
 AFRAME.GLTFModelPlus.registerComponent("gltf-model-plus", "gltf-model-plus");
@@ -25,6 +28,7 @@ AFRAME.GLTFModelPlus.registerComponent("scale-audio-feedback", "scale-audio-feed
 AFRAME.GLTFModelPlus.registerComponent("animation-mixer", "animation-mixer");
 AFRAME.GLTFModelPlus.registerComponent("loop-animation", "loop-animation");
 AFRAME.GLTFModelPlus.registerComponent("shape", "shape");
+AFRAME.GLTFModelPlus.registerComponent("heightfield", "heightfield");
 AFRAME.GLTFModelPlus.registerComponent(
   "box-collider",
   "shape",
@@ -44,7 +48,13 @@ AFRAME.GLTFModelPlus.registerComponent(
     };
   })()
 );
-AFRAME.GLTFModelPlus.registerComponent("visible", "visible");
+AFRAME.GLTFModelPlus.registerComponent("visible", "visible", (el, componentName, componentData) => {
+  if (typeof componentData === "object") {
+    el.setAttribute(componentName, componentData.visible);
+  } else {
+    el.setAttribute(componentName, componentData);
+  }
+});
 AFRAME.GLTFModelPlus.registerComponent("spawn-point", "spawn-point");
 AFRAME.GLTFModelPlus.registerComponent("hoverable", "hoverable");
 AFRAME.GLTFModelPlus.registerComponent("sticky-zone", "sticky-zone");
@@ -58,4 +68,7 @@ AFRAME.GLTFModelPlus.registerComponent("nav-mesh", "nav-mesh", (el, _componentNa
       nav.loadMesh(node, zone);
     }
   });
+  // There isn't actually an a-frame nav-mesh component, but we want to tag this el as a nav-mesh since
+  // nav-mesh-helper will query for it later.
+  el.setAttribute("nav-mesh");
 });
diff --git a/src/hub.js b/src/hub.js
index 6bc7382c84b8ec3cf3af71914a592ca853f76d1b..40c4f178e94b448c379d2d5136a01ea2b141ac5d 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -133,6 +133,7 @@ import "./components/cardboard-controls";
 
 import "./components/cursor-controller";
 
+import "./components/heightfield";
 import "./components/nav-mesh-helper";
 import "./systems/tunnel-effect";