跪拜 Guibai
← Back to the summary

Building a 3D Geo-Viz Engine in Vanilla Three.js: 12 Pitfalls from GeoJSON to Shader Flylines

Pure vanilla HTML/JS, no framework, no build tools, Three.js r128, from a single file to production delivery.

Image description

Origin

Over the years of doing data visualization, I've used ECharts' 3D maps a lot, but I always felt they weren't "free" enough—wanting to add a custom Shader flyline, a geometry growth animation, or precisely control the glow range, I was always constrained by the chart library's encapsulation.

So I decided to write one from scratch using Three.js.

The project is codenamed LockScope (Chinese name: 瞰境), positioned as a pure frontend 3D geographic data visualization engine. From the first version of the national map to the final production delivery for Zhejiang Province's prefecture-level cities, it went through 12 version iterations and encountered 12 real pitfalls. This article breaks down the entire process—how the architecture was designed, how the pitfalls were filled, and how the effects were achieved.

Final result: 3D extruded map + conical light pillars + Shader flowing flylines + glow post-processing + three color scheme hot-switching, all implemented in pure vanilla code.

Image description

Image description Image description

Technology Choices

Consideration Choice Reason
3D Engine Three.js r128 Mature ecosystem, loaded directly via CDN, no npm needed
Build Tool None Pure <script> tags, IIFE encapsulation, ES5 compatibility
Framework None No Vue/React/jQuery, DOM manipulation written by hand
Animation Library None requestAnimationFrame + math functions, no Tween.js
Post-Processing UnrealBloomPass Glow is the soul of the tech aesthetic
Labels CSS2DRenderer HTML labels are much more flexible than Sprite text
Geographic Data GeoJSON Local JSON, no dependency on third-party map APIs

Overall approach: Avoid any dependency that can be avoided. The delivery directory is just a bunch of static files that run when dropped onto any HTTP server.

Architecture Design

Coordinate System: Z-up to Y-up Conversion

This is the first problem that needs to be thought through clearly.

After mapping geographic longitude and latitude to a plane, it naturally forms a Z-up coordinate system—X points east, Y points north, Z is the height. But Three.js defaults to Y-up, and OrbitControls is also designed around the Y axis.

If the map is built directly in Y-up, the Z axis becomes the "horizontal direction," making the lat/lng mapping very awkward.

The solution is to introduce a root Group:

// Build everything inside the map using Z-up
var root = new THREE.Group();
root.rotation.x = -Math.PI / 2;  // Rotate the whole thing to the Y-up world
scene.add(root);

root.add(mapGroup);     // 3D map geometry
root.add(markerGroup);  // Light pillars + labels
root.add(flyGroup);     // Flylines + particles
root.add(decorGroup);   // Decorative rings + star points

This way, the map construction logic remains intuitive (Z is height), while the rendering layer is compatible with Three.js's Y-up ecosystem. One rotation solves all problems.

Projection Function

For small-scale geographic data, a simplified Equirectangular projection is sufficient:

function proj(lng, lat) {
  return {
    x: (lng - mapCenter.lng) * mapScale,
    y: (lat - mapCenter.lat) * mapScale
  };
}

Automatically calculate the GeoJSON bounding box, center it, and scale it to fitSize (default 38 units). The precision is sufficient for provincial-level data; national-level data requires switching to Mercator.

Three-Layer Configuration Architecture

CONFIG (Basic configuration, rarely changed)
  ├── geojson path, title, zoom size, extrusion height
  └── hubCity flyline hub city

settings (User-adjustable settings)
  ├── texture / textureUrl   Texture mode
  ├── colorScheme            Color scheme
  ├── heightSource + mult    Pillar height data source + multiplier
  ├── 5 display toggles
  └── cameraView             Camera view

COLOR_PRESETS (Color presets)
  └── teal / gold / red, each with 13 color values

A key design for color schemes is the CSS variable + JS color dual-track system: Three.js materials directly read numeric color values, while DOM elements synchronize CSS variables via applyCSSColors(). When switching color schemes, both sides change together.

Core Effect Implementation

1. ExtrudeGeometry: Extruding Map Blocks

Convert GeoJSON Polygon/MultiPolygon into THREE.Shape, then extrude using ExtrudeGeometry:

var shape = new THREE.Shape();
shape.moveTo(points[0].x, points[0].y);
for (var i = 1; i < points.length; i++) {
  shape.lineTo(points[i].x, points[i].y);
}

var geo = new THREE.ExtrudeGeometry(shape, {
  depth: CONFIG.extrudeHeight,  // 0.65
  bevelEnabled: true,
  bevelThickness: 0.02,
  bevelSize: 0.02,
  bevelSegments: 1
});

It looks simple, but this hides the biggest pitfall of the entire project.

2. Global UV: Fixing Texture Fragmentation (Pitfall #1)

ExtrudeGeometry generates independent 0~1 UVs for each Shape. This means a satellite texture gets tiled once on each city block, visually shattering it completely.

The solution is a two-pass UV rewrite:

// First pass: iterate all rings, calculate the global bounding box
var gMinX = Infinity, gMaxX = -Infinity;
var gMinY = Infinity, gMaxY = -Infinity;
walkRings(geo, function(x, y) {
  gMinX = Math.min(gMinX, x); gMaxX = Math.max(gMaxX, x);
  gMinY = Math.min(gMinY, y); gMaxY = Math.max(gMaxY, y);
});

// Second pass: rewrite UVs for the top and bottom faces
var uv = geo.attributes.uv;
var pos = geo.attributes.position;
for (var i = 0; i < uv.count; i++) {
  var px = pos.getX(i), py = pos.getY(i), pz = pos.getZ(i);
  if (pz >= topZ - bevT || pz <= bevT) {  // Top or bottom face
    uv.setXY(i,
      (px - gMinX) / gSpanX,
      (py - gMinY) / gSpanY
    );
  }
}
uv.needsUpdate = true;

The core idea: map the UV of each city block to its normalized position in the global coordinate system, rather than each block's own 0~1. This way, the entire satellite image is continuously laid across all city blocks.

3. Conical Light Pillars

One light pillar per city, using a double-layer cone + glow + pulse ring:

CylinderGeometry defaults to the Y axis, but our map is in Z-up space, requiring a rotation:

coneGeo.rotateX(Math.PI / 2);           // Y axis → Z axis
coneGeo.translate(0, 0, height / 2);    // Move the bottom face to the ground

Pillar height is mapped via a data source, taking GDP as an example:

var norm = (val - min) / (max - min);   // 0~1
var height = (3 + norm * 6) * multiplier; // 3~9 units

Supports six data sources: GDP, population, number of enterprises, growth rate, uniform, and random, with a multiplier adjustable from 0.3 to 3.0.

4. Flowing Flylines: Shader + TubeGeometry

This is the most visually striking part, and also the one with the most pitfalls.

Why not use THREE.Line? (Pitfall #6)

THREE.Line's lineWidth is fixed at 1px in the WebGL implementation of the vast majority of browsers and cannot be thickened. Setting lineWidth=3 has no effect.

Switch to TubeGeometry tubular mesh:

var curve = new THREE.QuadraticBezierCurve3(
  new THREE.Vector3(hub.x, hub.y, CONFIG.extrudeHeight),
  new THREE.Vector3(
    (hub.x + pt.x) / 2, (hub.y + pt.y) / 2,
    Math.max(hub.h, pt.h) + Math.max(2.5, dist * 0.18)
  ),
  new THREE.Vector3(pt.x, pt.y, CONFIG.extrudeHeight)
);
var lineGeo = new THREE.TubeGeometry(curve, 80, 0.03, 6, false);

Bezier Curve Parameter Order (Pitfall #5)

The signature for QuadraticBezierCurve3 is (start, controlPoint, end), not (start, end, controlPoint).

This pitfall wasted a lot of time—with the parameters reversed, the curve first flew over the target city and then folded back to mid-air, forming an abnormally huge arc that spanned the entire screen.

Shader Three-Layer Animation

The Fragment Shader stacks three layers of effects:

// 1. Draw-in animation: uDraw goes from 0→1, discard undrawn parts
if (vProgress > uDraw) discard;

// 2. Dual sine wave pulse flow
float wave1 = sin(vProgress * 28.0 - uTime * 3.2);
float wave2 = sin(vProgress * 18.0 - uTime * 2.1 + 1.5);
float pulse = (wave1 * 0.5 + wave2 * 0.3 + 0.7);

// 3. Draw a bright leading head
float head = smoothstep(uDraw - 0.02, uDraw, vProgress) * 1.2;

gl_FragColor = vec4(uColor * pulse + uGlow * head, alpha);

The two sine waves have different frequencies and speeds, creating a staggered, organic flow rather than mechanical, evenly-spaced stripes.

TubeGeometry Vertex Structure (Pitfall #7)

When passing the aProgress attribute to the Shader, you need to know how TubeGeometry arranges its vertices:

Each ring has radialSegments + 1 vertices (not radialSegments)

With radialSegments=6, each ring has 7 vertices, totaling 81 rings (80 segments + 1), resulting in 567 vertices. If the attribute is written assuming 6 vertices, the UVs will be misaligned.

var vertsPerRing = radialSeg + 1;  // 7
for (var i = 0; i <= segs; i++) {
  var pp = i / segs;
  for (var j = 0; j < vertsPerRing; j++) {
    progArr[i * vertsPerRing + j] = pp;
  }
}

Particle Trails + Destination Pulse

Each flyline is paired with 4 Sprite particles moving along the curve:

var t = (clock.getElapsedTime() * speed + phase) % 1;
var pos = curve.getPoint(t);
particle.position.copy(pos);
particle.material.opacity = Math.sin(t * Math.PI);  // Fade in and out at both ends

A RingGeometry pulse ring is placed at the destination, expanding every 2 seconds: scale goes from 0.5 to 4.7, opacity decays from 0.5 to 0.

The flyline color is hardcoded to golden yellow (#ffc040), not switching with the color scheme—gold has good contrast against all three base color schemes: teal, blue-gold, and dark red.

5. Glow Post-Processing

UnrealBloomPass is key to the tech aesthetic, but its parameters require repeated tuning:

bloomPass = new THREE.UnrealBloomPass(
  new THREE.Vector2(w, h),
  0.35,  // strength
  0.35,  // radius
  0.55   // threshold
);

threshold=0.55 is a critical value. Set too low, the entire map washes out white, with the texture being bleached by the glow; set too high, the luminous feel of the light pillars and flylines doesn't come through. 0.55 means only areas with brightness exceeding middle gray produce a glow.

Combined with material settings emissive: 0x000000, emissiveIntensity: 0, this ensures the glow comes only from the AdditiveBlending light pillars and flylines, without affecting the map texture.

6. Hot Scene Rebuilding

When switching textures, color schemes, or pillar height data sources, the page cannot be refreshed (as it would lose the current camera view and selection state). This is handled by rebuildScene():

function rebuildScene() {
  disposeGroup(mapGroup);
  disposeGroup(markerGroup);
  disposeGroup(flyGroup);
  disposeGroup(decorGroup);
  // Clean up star points...
  // Update color references...
  // Reload texture → build map → light pillars → flylines → decorations
}

Disposal must be recursively traversed, and it must handle Array materials (ExtrudeGeometry's material is [topMat, sideMat]) and shared textures:

function disposeGroup(group) {
  group.traverse(function(obj) {
    if (obj.geometry) obj.geometry.dispose();
    if (obj.material) {
      var mats = Array.isArray(obj.material) ? obj.material : [obj.material];
      mats.forEach(function(m) {
        if (m.map && m.map !== pTex) m.map.dispose();  // Don't release shared textures
        m.dispose();
      });
    }
  });
  // ...clear children
}

Pitfall #8: arguments.callee is forbidden in strict mode; recursion must use a named inner function.

Interaction System

Texture System

Except for the externally loaded satellite texture, all textures are procedurally generated via Canvas:

Texture Size Use
pTex 512² Map top face (satellite/terrain/solid color)
sTex 128×512 Map side face (gradient + vertical lines + highlight)
cnTex 64×512 Light pillar cone face
glTex 128² Glow Sprite
ptTex 64² Flyline particle

Performance iron rules:

Summary of 12 Pitfalls

# Problem Root Cause Solution
1 ExtrudeGeometry texture fragmentation Each Shape has independent 0~1 UV Two-pass global bounding box UV rewrite
2 Glow washes out map texture emissive + strong Bloom emissive=0, threshold≥0.55
3 OrbitControls rotation anomaly Z-up vs Y-up conflict root.rotation.x = -π/2
4 Conical light pillar wrong direction CylinderGeometry defaults to Y axis rotateX(π/2) + translate
5 Flyline abnormally large arc Bezier parameter order reversed (start, control, end)
6 Flyline cannot be thickened WebGL Line lineWidth=1px Replace with TubeGeometry
7 Shader attribute misalignment TubeGeometry has radialSeg+1 vertices per ring vertsPerRing = radialSeg + 1
8 strict mode error arguments.callee is forbidden Named inner function recursion
9 DOM doesn't change on color scheme switch Three.js and CSS are two separate systems applyCSSColors() syncs CSS variables
10 Memory grows after rebuild geometry/material not released disposeGroup recursive dispose
11 Hover clears selection state Single currentMesh variable Separate hovered/selected
12 bounds is not defined Global variable deleted during refactoring Use local variable instead

Among these 12 pitfalls, 1, 5, 6, and 7 are specific to Three.js and not heavily emphasized in the documentation. It's recommended to scan these before starting a similar project.

Productionization

After the functionality stabilized, three things were done:

1. File Directory Separation

The single index.html file was split into a standard directory structure:

lockscope/
├── index.html
├── css/style.css
├── js/main.js
├── images/terrain_texture.jpg
├── data/js.geojson
└── docs/

2. Documentation Delivery

Performance Data

Final Thoughts

From the first version to production, the biggest takeaway is: Three.js's API isn't hard; what's hard are the underlying concepts like coordinate systems, UVs, and geometry topology. Once you understand how ExtrudeGeometry generates vertices, how TubeGeometry arranges rings, and how UVs map, the rest is just combination and parameter tuning.

The benefit of the pure vanilla approach is that delivery is extremely simple—a bunch of static files, no node_modules needed, no build steps, runs when dropped onto any web server. For scenarios like data dashboards where the deployment environment is constrained, this point is very important.


瞰境 LockScope · A 3D Data Visualization Map Engine based on Three.js

Comments

Top 3 of 5 from juejin.cn, machine-translated. The original thread is authoritative.

漏刻有时 1 likes

Use AI to turn development into a product

漏刻有时

Focus on improving the visual appeal.

我也要在julius_bar里藏钱

Is it open source? Is there source code available?

漏刻有时

It's a company project, not open source for now.

donim

Overdone, dime-a-dozen stuff