Skip to content

Geographic Parcel

An advanced example that highlights land parcels directly on the Gaussian Splats using a Spark dyno GLSL world modifier. Parcel boundaries are defined as GPS polygons, projected into the capture's coordinate frame, and used to tint and animate a lifting motion on the splats inside each parcel. The example cycles through a series of parcels to show the effect updating in real time.

Source Code#

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
    <title>Teleport.js Click to GPS Sample</title>
    <style>
      body { margin: 0; overflow: hidden; background: #222; }
      canvas { display: block; width: 100vw; height: 100vh; }
    </style>
    <script type="importmap">
      {
        "imports": {
          "three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
          "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
          "@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/2.1.0/spark.module.js",
          "teleport": "https://d1ziouw89q7sp5.cloudfront.net/teleport-js/0.0.2/teleport.module.js"
        }
      }
    </script>
  </head>
  <body>
    <script type="module">
      import * as THREE from "three";
      import * as TELEPORT from "teleport";
      import { dyno } from "@sparkjsdev/spark";

      const PARCEL_GLSL = `
uniform vec2 polygon[8];

const vec4 PARCEL_COLOR = vec4(0.2, 0.6, 1.0, 1.0);
const vec4 OUTER_COLOR  = vec4(0.0, 0.0, 0.0, 0.0);

float signedDistanceToPoly(float pointCount, vec2 queryPoint) {
  float closestDistSq = 9999.0;
  float windingNumber = 0.0;
  for (int i = 0; i < 8; i++) {
    if (float(i) >= pointCount) break;
    int next = i + 1; if (float(next) >= pointCount) next = 0;
    vec2 vertCurrent   = polygon[i];
    vec2 vertNext      = polygon[next];
    vec2 edgeDir       = vertNext - vertCurrent;
    vec2 toQuery       = queryPoint - vertCurrent;
    float edgeLenSq    = max(dot(edgeDir, edgeDir), 0.0001);
    vec2 closestOnEdge = toQuery - edgeDir * clamp(dot(toQuery, edgeDir) / edgeLenSq, 0.0, 1.0);
    closestDistSq = min(closestDistSq, dot(closestOnEdge, closestOnEdge));
    bool currentAbove = vertCurrent.y > queryPoint.y;
    bool nextAbove    = vertNext.y    > queryPoint.y;
    if (currentAbove != nextAbove) {
      float crossX = (vertNext.x - vertCurrent.x) * (queryPoint.y - vertCurrent.y) / (vertNext.y - vertCurrent.y + 1e-6) + vertCurrent.x;
      if (queryPoint.x < crossX) { windingNumber += nextAbove ? 1.0 : -1.0; }
    }
  }
  return sqrt(closestDistSq) * (windingNumber == 0.0 ? 1.0 : -1.0);
}

vec4 fetchParcelColor(vec3 worldPos, float polyCount) {
  vec2 p = worldPos.xz;
  float dist   = polyCount > 0.0 ? signedDistanceToPoly(polyCount, p) : 9999.0;
  bool  inside = dist < 0.0;
  return inside ? PARCEL_COLOR : OUTER_COLOR;
}

vec3 applyParcelLift(vec3 worldPos, float polyCount, float phase, float amplitude) {
  if (polyCount <= 0.0 || amplitude == 0.0) return worldPos;

  vec2 p = worldPos.xz;

  float dist  = signedDistanceToPoly(polyCount, p);
  float mask  = dist < 0.0 ? 1.0 : 0.0;
  float slerp  = 0.5 * (1.0 - cos(6.28318530718 * clamp(phase, 0.0, 1.0)));

  worldPos.y += amplitude * slerp * mask;
  return worldPos;
}
`;

      class Parcels {
        constructor({ model, gpsTransform }) {
          this._model = model;
          this.gpsTransform = { M: new THREE.Matrix4().set(...gpsTransform.matrix), refLat: gpsTransform.gps.latitude, refLon: gpsTransform.gps.longitude };
          this.parcelModifier = null;
          this.polyPoints = [];
          this.polygonUniform = new THREE.Uniform(this.ToUniformVec2Array(this.polyPoints));
          this.polygonCount = dyno.dynoFloat(0);
          this.uParcelHeightOffset = dyno.dynoFloat(0.0);
          this.LiftPhase = dyno.dynoFloat(0.0);
        }

        async init() {
          const parcelGlsl = PARCEL_GLSL;
          const parcelParameters = new dyno.Dyno({
            inTypes: { gsplat: dyno.Gsplat,polyCount: "float", parcelHeightOffset: "float", liftPhase: "float" },
            outTypes: { gsplat: dyno.Gsplat }, generate: ({ inputs, outputs }) => ({
              globals: [parcelGlsl],
              statements: [dyno.unindent(`
                  ${outputs.gsplat} = ${inputs.gsplat};
                  vec4 pc = fetchParcelColor(${inputs.gsplat}.center,${inputs.polyCount});
                  if (pc.a > 0.01)${ outputs.gsplat }.rgba = vec4(mix(${inputs.gsplat}.rgba.rgb, pc.rgb, 0.5), ${inputs.gsplat}.rgba.a);
                  ${outputs.gsplat}.center = applyParcelLift(${outputs.gsplat}.center, ${inputs.polyCount}, ${inputs.liftPhase}, ${inputs.parcelHeightOffset});
                `)],
              uniforms: { polygon: this.polygonUniform },
            }),
          });

          this.parcelModifier = dyno.dynoBlock(
            { gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat },
            ({ gsplat }) => ({ gsplat: parcelParameters.apply({ gsplat,polyCount: this.polygonCount,parcelHeightOffset: this.uParcelHeightOffset, liftPhase: this.LiftPhase }).gsplat })
          );
          return this;
        }

        Geodetic2enu(lat, lon, refLat, refLon) {
          const R = 6378137.0;
          const Lat = (lat - refLat) * Math.PI / 180;
          const Lon = (lon - refLon) * Math.PI / 180;
          return { e: Lon * R * Math.cos(refLat * Math.PI / 180), n: Lat * R, u: 0 };
        }

        SetBoundary(coords) {
          const transform = this.gpsTransform.M.elements;
          const { refLat, refLon } = this.gpsTransform;
          const xAxisEast = transform[0], xAxisNorth = transform[1];
          const zAxisEast = transform[8], zAxisNorth = transform[9];
          const originEast = transform[12], originNorth = transform[13];
          const invDet = 1 / (xAxisEast * zAxisNorth - zAxisEast * xAxisNorth);
          this.polyPoints = coords.map(([lat, lon]) => {
            const { e: east, n: north } = this.Geodetic2enu(lat, lon, refLat, refLon);
            const offsetEast = east - originEast;
            const offsetNorth = north - originNorth;
            const localX = (offsetEast * zAxisNorth - zAxisEast * offsetNorth) * invDet;
            const localZ = (xAxisEast * offsetNorth - offsetEast * xAxisNorth) * invDet;
            return { x: localZ, y: localX };
          });
          this.polygonUniform.value = this.ToUniformVec2Array(this.polyPoints);
          this.polygonCount.value = this.polyPoints.length;
          this._model.updateVersion();
        }

        ToUniformVec2Array(pts) {
          const N = 8;
          const arr = [];
          for (let i = 0; i < N; i++) {
            const p = pts[i] ?? { x: 0, y: 0 };
            arr.push(new THREE.Vector2(p.x, p.y));
          }
          return arr;
        }
      }

      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1e6);
      const renderer = new THREE.WebGLRenderer({ antialias: false, logarithmicDepthBuffer: true });
      renderer.setSize(window.innerWidth, window.innerHeight);
      renderer.setPixelRatio(window.devicePixelRatio);
      document.body.appendChild(renderer.domElement);

      const teleportManager = new TELEPORT.Manager({ renderer: renderer, scene: scene, camera: camera });
      const capture = await teleportManager.loadCapture("524ee89f293a4a2e907009191ba7b9f4", { coordSystem: "colmap" });
      const parcels = await new Parcels({ model: capture.mesh, gpsTransform: capture.metadata.gps_transform }).init();

      const parcel1 = [[61.44130412, 23.81653750], [61.44128557, 23.81611655], [61.44108545, 23.81615214], [61.44109309, 23.81664970]];
      const parcel2 = [[61.44110626, 23.81659589], [61.44089175, 23.81671361], [61.44083362, 23.81632414], [61.44108546, 23.81622856]];
      const parcel3 = [[61.44107786, 23.81618021], [61.44084886, 23.81638620], [61.44079101, 23.81602923], [61.44104879, 23.81587534]];
      const parcel4 = [[61.44104769, 23.81586642], [61.44078859, 23.81602277], [61.44075739, 23.81569451], [61.44101905, 23.81556022]];
      const parcel5 = [[61.44102170, 23.81553493], [61.44074146, 23.81571224], [61.44070234, 23.81540734], [61.44075785, 23.81513393], [61.44100846, 23.81540676]];
      const parcel6 = [[61.44099475, 23.81538943], [61.44075279, 23.81516678], [61.44081940, 23.81486222], [61.44108482, 23.81512748]];
      const parcel7 = [[61.44082663, 23.81488284], [61.44089584, 23.81454355], [61.44111919, 23.81477880], [61.44106064, 23.81509806]];
      const parcel8 = [[61.44113245, 23.81493517], [61.44132345, 23.81510186], [61.44126741, 23.81543671], [61.44106720, 23.81528484]];
      const parcel9 = [[61.44126681, 23.81576011], [61.44103931, 23.81578548], [61.44100721, 23.81540622], [61.44105329, 23.81524182], [61.44126252, 23.81540717]];
      const parcel10 = [[61.44104095, 23.81579494], [61.44127664, 23.81578302], [61.44128889, 23.81614431], [61.44107307, 23.81618297]];

      const parcelCycle = [parcel1, parcel2, parcel3, parcel4, parcel5, parcel6, parcel7, parcel8, parcel9, parcel10];
      const interval = 600;
      parcels.uParcelHeightOffset.value = 8;
      parcels.SetBoundary(parcelCycle[0]);

      capture.mesh.worldModifier = parcels.parcelModifier;
      capture.mesh.updateGenerator();

      camera.position.set(524.952, 103.926, 300.973);
      camera.quaternion.set(-0.240106, 0.545967, 0.166971, 0.785105);
      camera.updateProjectionMatrix();

      let currentParcel = 0;
      const parcelClockStart = performance.now();
      const updateParcelAnimation = () => {
        const elapsed = performance.now() - parcelClockStart;
        const idx = Math.floor(elapsed / interval) % parcelCycle.length;
        if (idx !== currentParcel) {
          currentParcel = idx;
          parcels.SetBoundary(parcelCycle[idx]);
        }
        parcels.LiftPhase.value = (elapsed % interval) / interval;
        capture.mesh.updateVersion();
      };

      window.addEventListener("resize", () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
      });

      renderer.setAnimationLoop(() => {
        updateParcelAnimation();
        camera.updateMatrixWorld();
        renderer.render(scene, camera);
      });
    </script>
  </body>
</html>