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.
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.
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:
- Outer Cone: CylinderGeometry, Canvas gradient texture, AdditiveBlending
- Inner Cone: Thinner and brighter, simulating the light core
- Top Glow: Sprite + radial gradient, breathing scale animation
- Dual Pulse Rings: RingGeometry, out-of-phase diffusion
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.calleeis forbidden in strict mode; recursion must use a named inner function.
Interaction System
- Raycaster Picking: Only detects cityMeshes (map blocks), excluding light pillars and decorations
- Separation of hoveredMesh and selectedMesh (Pitfall #11): Hovering over other cities does not clear the highlight state of the clicked selection
- Independent Material Instance per City:
topMat.clone()/sideMat.clone(), only changing the current mesh'smaterial[0].emissiveon hover - CSS2DRenderer Label Layer:
pointer-events: none, does not block mouse events - City Info Panel: Clicking a city pops up GDP/population/enterprises/growth rate + regional ranking
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:
- Canvas textures do not exceed 512²
- Textures are shared globally, not cloned
pixelRatio = Math.min(devicePixelRatio, 1.5)- No shadows, use AdditiveBlending to simulate glow
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
- Operation manual HTML (dark tech style, consistent with the product's visual style)
- Technical log Markdown (architecture, API, pitfall records, extension guide)
Performance Data
- 11 prefecture-level cities, about 300+ vertex rings, first-screen render < 500ms
- Normal frame rate 60fps (mid-range laptop integrated graphics)
- Stable memory usage (no growth after rebuild, dispose verified)
- Frame rate with Bloom on is about 45~55fps, can be reduced by lowering glow intensity via the control panel
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
Top 3 of 5 from juejin.cn, machine-translated. The original thread is authoritative.
Use AI to turn development into a product
Focus on improving the visual appeal.
Is it open source? Is there source code available?
It's a company project, not open source for now.
Overdone, dime-a-dozen stuff