diff --git a/src/components/heightfield.js b/src/components/heightfield.js
new file mode 100644
index 0000000000000000000000000000000000000000..cdb6b0014b52d51269fcc8039ce9210b9585146a
--- /dev/null
+++ b/src/components/heightfield.js
@@ -0,0 +1,68 @@
+/* global CANNON */
+AFRAME.registerComponent("heightfield", {
+  init() {
+    this.el.addEventListener("componentinitialized", e => {
+      if (e.detail.name === "static-body") {
+        this.initShape(this.el.components["static-body"]);
+      }
+    });
+    this.el.setAttribute("static-body", { shape: "none", mass: 0 });
+  },
+  initShape(body) {
+    console.log("BPDEBUG body", 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/gltf-component-mappings.js b/src/gltf-component-mappings.js
index 5dde948e65abc8b1ce5ea461a4ac38994047b88c..3576a1049a8bbb87dcae54a8da29298bc1a6be83 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -28,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",
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";