Creating a 3D Cityscape with Lighting, Shadows, and Radar Effects Using Three.js
This tutorial demonstrates how to build a 3D city model in Three.js by adding ambient and directional lighting with shadows, generating random building geometries, customizing shaders for top‑view UV mapping, and implementing animated radar diffusion and scanning effects through shader uniform updates.
1. Draw a City Model
Add Lighting and Enable Shadows
// Enable renderer shadows
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// Ambient light (soft white)
const light = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(light);
// Directional light (blue night sky)
const dirLight = new THREE.DirectionalLight(0x0000ff, 3);
dirLight.position.set(50, 50, 50);
this.scene.add(dirLight);Configure Directional Light Shadows
// Enable shadows for the light
dirLight.castShadow = true;
// Shadow camera bounds (orthographic)
dirLight.shadow.camera.top = 100;
dirLight.shadow.camera.bottom = -100;
dirLight.shadow.camera.left = -100;
dirLight.shadow.camera.right = 100;
// Near and far planes
dirLight.shadow.camera.near = 1;
dirLight.shadow.camera.far = 200;
// Shadow map resolution
dirLight.shadow.mapSize.set(1024, 1024);The directional light uses an orthographic shadow camera because its rays are parallel, similar to a parallel‑view camera.
Add Buildings
// Add a ground plane
const pg = new THREE.PlaneGeometry(100, 100);
const pm = new THREE.MeshStandardMaterial({
color: new THREE.Color('gray'),
transparent: true,
side: THREE.FrontSide
});
const plane = new THREE.Mesh(pg, pm);
plane.rotateX(-Math.PI * 0.5);
plane.receiveShadow = true;
this.scene.add(plane);
// Generate random buildings
this.geometries = [];
const helper = new THREE.Object3D();
for (let i = 0; i < 100; i++) {
const h = Math.round(Math.random() * 15) + 5;
const x = Math.round(Math.random() * 50);
const y = Math.round(Math.random() * 50);
helper.position.set((x % 2 ? -1 : 1) * x, h * 0.5, (y % 2 ? -1 : 1) * y);
const geometry = new THREE.BoxGeometry(5, h, 5);
helper.updateWorldMatrix(true, false);
geometry.applyMatrix4(helper.matrixWorld);
this.geometries.push(geometry);
}
// Merge all boxes into one geometry
const mergedGeometry = BufferGeometryUtils.mergeGeometries(this.geometries, false);
// Load building texture
const texture = new THREE.TextureLoader().load('assets/image.jpg');
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const material = new THREE.MeshStandardMaterial({ map: texture, transparent: true });
const cube = new THREE.Mesh(mergedGeometry, material);
cube.castShadow = true;
cube.receiveShadow = true;
this.scene.add(cube);The result looks like a cluster of high‑rise buildings; ignore the roof windows—they are just artistic details.
2. Radar Diffusion and Scan Effects
Modify Building Material Shader to Compute Top‑View UV
material.onBeforeCompile = (shader, render) => {
this.shaders.push(shader);
// Uniforms
shader.uniforms.uSize = { value: 50 };
shader.uniforms.uTime = { value: 0 };
// Vertex shader: add uSize and vUv
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
` uniform float uSize;
varying vec2 vUv;
void main() {`
);
shader.vertexShader = shader.vertexShader.replace(
'#include
',
`#include
// Compute top‑view UV
vUv = position.xz / uSize;`
);
// Fragment shader: add time and color blending
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`varying vec2 vUv;
uniform float uTime;
void main() {`
);
shader.fragmentShader = shader.fragmentShader.replace(
'#include
',
`#include
// Gradient color overlay
gl_FragColor.rgb = gl_FragColor.rgb + mix(vec3(0,0.5,0.5), vec3(1,1,0), vUv.y);`
);
};For the ground plane, the UV calculation must account for the -90° rotation and the fact that the plane uses the XY plane:
vUv = vec2(position.x, -position.y) / uSize;Now the building and plane share the same top‑view UV space.
Radar Diffusion Effect
shader.uniforms.uColor = { value: new THREE.Color('#00FFFF') };
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
void main() {`;
const fragmentShader2 = `#include
// Distance from center
float d = length(vUv);
if (d >= uTime && d <= uTime + 0.1) {
// Diffusion ring
gl_FragColor.rgb = gl_FragColor.rgb + mix(uColor, gl_FragColor.rgb, 1.0 - (d - uTime) * 10.0) * 0.5;
}`;
shader.fragmentShader = shader.fragmentShader.replace('void main() {', fragmentShader1);
shader.fragmentShader = shader.fragmentShader.replace('#include
', fragmentShader2);
// Animate the time uniform
animateAction() {
if (this.shaders?.length) {
this.shaders.forEach(shader => {
shader.uniforms.uTime.value += 0.005;
if (shader.uniforms.uTime.value >= 1) {
shader.uniforms.uTime.value = 0;
}
});
}
}The animated ring expands like a radar pulse, creating a cool 3‑D diffusion effect.
Radar Scan Effect
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
// Rotation matrix
mat2 rotate2d(float angle) {
return mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
}
// Radar scan sector
float vertical_line(in vec2 uv) {
if (uv.y > 0.0 && length(uv) < 1.2) {
float theta = mod(180.0 * atan(uv.y, uv.x) / 3.14, 360.0);
float gradient = clamp(1.0 - theta / 90.0, 0.0, 1.0);
return 0.5 * gradient;
}
return 0.0;
}
void main() {`;
const fragmentShader2 = `#include
mat2 rotation_matrix = rotate2d(- uTime * PI * 2.0);
gl_FragColor.rgb = mix(gl_FragColor.rgb, uColor, vertical_line(rotation_matrix * vUv));`;
shader.fragmentShader = shader.fragmentShader.replace('void main() {', fragmentShader1);
shader.fragmentShader = shader.fragmentShader.replace('#include
', fragmentShader2);The scan creates a rotating sector that sweeps across the scene, adding another dynamic radar visual.
GitHub Repository
https://github.com/xiaolidan00/my-earth
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.