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>