Chapter 10: Custom Shader Styling

Attribute-driven color classification and 3D building gradients

Introduction

Up through Chapter 9, we integrated raster tiles and 3D Tiles, completing the basic rendering pipeline of the WebGIS viewer. However, the rendering so far has been centered around "fixed colors." In GIS applications, there are many situations where you want to dynamically change colors based on feature attribute data — color-coding by building height, heatmaps from statistical data, lighting changes based on time of day, and so on. Styling that visually conveys the meaning of data is essential.

In this chapter, we will learn how to achieve attribute-driven styling using Three.js custom shaders. We will cover the basics of GLSL ES, practical choropleth maps and height gradients, and real-time parameter updates, progressing step by step.

Control Panel

Use the controls below to change the 3D building shader in real time. After adjusting parameters, press the "Apply Shader" button.

#1a237e
#ff6f00

Three.js Material System

Three.js provides three levels of shader customization.

Level 1: Built-in Materials

MeshBasicMaterial, MeshStandardMaterial, etc. You can control color, texture, transparency, and other properties, but cannot modify the rendering logic itself. The current MVT renderer (mvt-mesh.ts) uses this level.

Level 2: onBeforeCompile Hook

A method that modifies the internal shader of built-in materials via string replacement. It allows you to replace specific logic while preserving existing lighting calculations, shadow maps, and other features. This is effective when you need to modify the material of meshes generated by a library, as with 3D Tiles.

Level 3: ShaderMaterial

A method where you write vertex and fragment shaders entirely from scratch. You have full control, but you need to implement Three.js built-in features (lighting, shadows, etc.) yourself. This is suitable when simple fill rendering is sufficient, such as with MVT polygons.

GLSL ES Basics

These are the basic concepts of GLSL ES (OpenGL ES Shading Language) used in WebGL.

QualifierDirectionUsage
attributeCPU -> VertexPer-vertex data (position, color, attribute values)
uniformCPU -> ShaderShared across all vertices (matrices, time, color parameters)
varyingVertex -> FragmentData interpolated between vertices

Uniforms and attributes automatically injected by Three.js:

Auto-injected variables
// Variables automatically available in vertex shaders
uniform mat4 projectionMatrix;  // Camera projection matrix
uniform mat4 modelViewMatrix;   // Model x View matrix
uniform mat4 modelMatrix;       // Model matrix
attribute vec3 position;        // Vertex position
attribute vec3 normal;          // Normal vector
attribute vec2 uv;              // Texture coordinates

Height Gradient for 3D Tiles

PLATEAU's LOD1 building data consists of box-shaped meshes for each building. By calculating the distance from the vertex position in ECEF coordinates to the Earth's center and subtracting the WGS84 ellipsoid radius (approximately 6,378,137 m), we can obtain an approximate building height.

We access the generated meshes via the onModelLoaded callback and modify the existing material's shader using onBeforeCompile.

custom-shader.ts (height gradient)
// Compute color based on height in the fragment shader
float h = length(vWorldPos) - 6378137.0;
float t = clamp(
  (h - uMinHeight) / (uMaxHeight - uMinHeight),
  0.0, 1.0
);
vec3 color = mix(uColorLow, uColorHigh, t);

length(vWorldPos) is the distance from the ECEF origin (Earth's center). Subtracting the surface radius gives an approximate building height. Strictly speaking, the WGS84 ellipsoid's flattening should be taken into account, but for color classification purposes the precision is sufficient.

Material Modification via onBeforeCompile

Three.js built-in shaders are composed of #include <chunk_name> directives. Using onBeforeCompile, we perform string replacements to inject custom logic.

How onBeforeCompile works
material.onBeforeCompile = (shader) => {
  // Add uniforms
  shader.uniforms.uMinHeight = { value: 0 };
  shader.uniforms.uMaxHeight = { value: 200 };

  // Add varying to vertex shader
  shader.vertexShader = shader.vertexShader
    .replace(
      '#include <common>',
      `#include <common>
       varying vec3 vWorldPos;`
    );

  // Mix height-based colors in fragment
  shader.fragmentShader = shader.fragmentShader
    .replace(
      '#include <color_fragment>',
      `#include <color_fragment>
       diffuseColor.rgb = mix(...);`
    );
};

Key shader chunks:

  • #include <common> — Constants and basic function definitions
  • #include <color_fragment> — Diffuse color determination
  • #include <worldpos_vertex> — World coordinate calculation
  • #include <lights_fragment_begin> — Start of lighting calculations

Time-based Lighting Changes

By passing the sun position as a uniform, we simulate lighting changes based on time of day. The sun direction vector is approximated from the solar declination (seasonal variation) and hour angle.

computeSunDirection
function computeSunDirection(
  hourUTC: number,
  dayOfYear: number
): THREE.Vector3 {
  // Solar declination (seasonal variation)
  const decl = 23.44 * Math.sin(
    2*PI/365 * (dayOfYear - 81)
  );
  // Hour angle
  const ha = (hourUTC - 12) * 15;
  // ECEF -> Scene coordinate transform
  return new Vector3(x, z, -y);
}

In the fragment shader, we use the Lambertian reflectance model (max(dot(normal, sunDir), 0.0)) to calculate diffuse light intensity from the dot product of the normal and sun direction. By adding ambient light, surfaces in shadow are prevented from going completely black.

Performance Considerations

OperationCostRecompile
Changing uniform valuesVery lowNot required
Changing attribute valuesModerateNot required
Adding/removing attributesHighNot required
Changing shader codeVery highRequired

Design principle: Pass dynamically changing parameters (color range, opacity, time, etc.) as uniforms, and pass feature-specific data (attribute values) as attributes. Shader code changes should be made at application startup and avoided at runtime.

Moving the time slider in the control panel above only updates the uniform value, so no shader recompilation occurs. On the other hand, switching shader modes (e.g., height gradient -> flat color) changes the shader code itself, requiring layer reconstruction.

Texture Lookup Tables

When using many color classes, adding if-else branches in the shader incurs GPU warp divergence penalties. Instead, using a 1D texture as a color palette enables fast lookups without branching.

Palette texture
// CPU side: Generate palette texture
const data = new Uint8Array(width * 4);
for (let i = 0; i < width; i++) {
  data[i*4+0] = colors[i].r * 255;
  data[i*4+1] = colors[i].g * 255;
  data[i*4+2] = colors[i].b * 255;
  data[i*4+3] = 255;
}

// GPU side: Texture lookup
float idx = floor(
  vValue * (uPaletteSize - 1.0)
) + 0.5;
vec2 uv = vec2(
  idx / uPaletteSize, 0.5
);
vec4 c = texture2D(uPalette, uv);

Coexistence with Material Pooling

The existing mvt-mesh.ts optimizes draw calls through material pooling. The same strategy is applied when introducing custom shaders.

In choropleth maps, vertex colors (attributes) distinguish each feature, making material sharing even easier. By sharing a single ShaderMaterial across all polygons and expressing color differences through attributes, draw calls can be kept to a minimum.

Custom material pool
const pool = new Map<string, ShaderMaterial>();

function createChoroplethMaterial(
  opacity = 0.7
): ShaderMaterial {
  const key = `choropleth-${opacity}`;
  let mat = pool.get(key);
  if (!mat) {
    mat = new ShaderMaterial({...});
    pool.set(key, mat);
  }
  return mat;
}

Adding Callbacks to Tiles3DAdapter

When the custom 3D Tiles adapter implemented in Chapter 9 loads a tile, the generated meshes can be accessed via the Tiles3DAdapterCallbacks onModelLoaded callback. Together with onModelDisposed, material replacement and cleanup are performed.

tiles3d-adapter.ts
interface Tiles3DAdapterCallbacks {
  onTilesetLoaded?: (sphere) => void;
  onLoadError?: (error, url) => void;
  onModelLoaded?: (
    scene: Object3D, tile: object
  ) => void;
  onModelDisposed?: (
    scene: Object3D
  ) => void;
}

Depending on the Tiles3DLayer's shaderMode property, the appropriate shader application function (applyHeightGradient, applyFlatColor, applySunLighting) is called in onModelLoaded. In onModelDisposed, custom materials are cleaned up via disposeCustomMaterials.

Summary

In this chapter, we learned how to introduce custom shaders at three levels:

  • Attribute-driven styling for MVT — Choropleth maps based on feature attributes using vertex color attributes and ShaderMaterial
  • Material replacement for 3D Tiles — Height gradients and time-based lighting achieved via onModelLoaded callback and onBeforeCompile/ShaderMaterial
  • Performance design — Leveraging uniform updates to avoid shader recompilation and reducing branching with texture LUTs

By combining these techniques, you can create GIS visualizations that visually convey the meaning of data. In the next chapter, we will discuss overall integration and future extensions.