Cesium 本身主要提供太阳光、环境光以及基于材质的场景渲染,并没有直接提供一个能够照亮 3D Tiles 的通用点光源接口。要在不修改 3D Tiles 原始材质的前提下实现动态点光源,可以使用一套“屏幕空间光照 + 点光源阴影贴图”的方案。
本文实现的效果包括:
- 支持动态添加多个点光源;
- 支持设置光源位置、颜色、强度和作用半径;
- 光照强度会随距离衰减;
- 建筑物能够遮挡灯光并产生阴影;
- 光源可以跟随鼠标在模型表面移动;
- 不需要修改 3D Tiles 的原始材质。

本文使用的是兰伯特漫反射、距离衰减和阴影,并不是包含金属度、粗糙度和高光计算的完整 PBR 光照。
一、实现原理
整体思路是:先在后处理着色器里读取场景的深度纹理,根据深度恢复当前像素对应的三维位置,然后再利用周围像素的深度估算出表面法线。
有了像素位置和法线之后,我就能算出当前像素到光源的方向和距离,通过法线和光照方向点乘计算漫反射,再结合光源颜色、强度以及距离衰减,算出这个点光源对当前像素的光照贡献。
另外我还给每个点光源创建了一个 Cesium 的阴影贴图。因为点光源是向四周发光的,所以这里用的是立方体阴影贴图,通过比较当前像素到光源的距离和阴影贴图里记录的深度,判断中间有没有建筑物遮挡。被遮挡的话,这个像素就不叠加灯光。
最后把所有点光源的光照结果累加到 Cesium 原始场景颜色上,这样就实现了支持多个动态点光源、距离衰减和建筑遮挡阴影的效果
整体方案由两部分组成:
- 使用
PostProcessStage 在后处理阶段计算点光源对屏幕像素的光照贡献;
- 使用
ShadowMap 为点光源生成立方体阴影贴图,判断光源与像素之间是否存在遮挡。
完整的数据流程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| Cesium 正常渲染场景 ↓ 获得场景颜色纹理和深度纹理 ↓ 根据深度恢复当前像素的观察坐标位置 ↓ 根据相邻像素位置估算表面法线 ↓ 计算兰伯特漫反射和距离衰减 ↓ 采样立方体阴影贴图,判断是否被遮挡 ↓ 累加所有点光源的颜色 ↓ 与 Cesium 原始场景颜色合成
|
1. 为什么使用后处理
3D Tiles 中可能包含大量模型和不同材质。如果逐个修改模型材质,不仅实现复杂,而且很难覆盖倾斜摄影、B3DM 等不同数据。
后处理发生在场景主体渲染完成之后。此时 Cesium 会向后处理着色器提供两张重要纹理:
colorTexture:场景原始颜色;
depthTexture:每个像素的深度。
只要能根据深度恢复像素对应的三维位置,就可以在屏幕空间重新计算光照,因此不需要侵入 3D Tiles 的原始材质。
2. 从深度恢复三维位置
后处理着色器最初只知道当前像素的屏幕纹理坐标。先读取并解包深度,再通过 Cesium 内置函数 czm_windowToEyeCoordinates 将窗口坐标还原为观察坐标:
1 2 3 4 5 6 7 8 9 10 11 12
| float depth = czm_unpackDepth( texture(depthTexture, v_textureCoordinates) );
vec2 windowPosition = v_textureCoordinates * czm_viewport.zw;
vec4 eyeCoordinate = czm_windowToEyeCoordinates(windowPosition, depth);
vec3 positionEC = eyeCoordinate.xyz / eyeCoordinate.w;
|
这里的 positionEC 就是当前像素在观察坐标系中的三维位置。后续的光源位置、光照方向和距离也必须转换到同一个坐标系中计算。
当深度大于等于 1.0 时,当前像素通常属于天空或没有有效几何体,可以直接返回原始颜色:
1 2 3 4
| if (depth >= 1.0) { out_FragColor = color; return; }
|
3. 根据相邻像素估算表面法线
后处理阶段拿不到 3D Tiles 原始顶点法线,因此需要利用深度纹理近似重建法线。
分别读取当前像素上、下、左、右相邻像素的深度,把它们恢复为观察坐标,然后构造水平和垂直两个切线方向:
1 2 3 4 5 6 7 8 9 10 11 12 13
| vec3 downUp = vectorFromOffset( eyeCoordinate, vec2(0.0, 1.0) );
vec3 leftRight = vectorFromOffset( eyeCoordinate, vec2(1.0, 0.0) );
vec3 normalEC = normalize( cross(leftRight, downUp) );
|
两个切线向量叉乘后得到观察坐标系下的表面法线。为了避免双线性采样把建筑边缘两侧的深度混在一起,后处理阶段应使用最近邻采样:
1 2 3 4 5
| new Cesium.PostProcessStage({ sampleMode: Cesium.PostProcessStageSampleMode.NEAREST, fragmentShader, uniforms });
|
这种法线是根据屏幕深度估算出来的,在物体轮廓、深度突变和细小结构附近可能存在误差,但用于城市模型的局部灯光通常已经足够。
4. 计算漫反射
已知表面位置、表面法线和光源位置后,可以使用兰伯特漫反射计算光照强度。
源码中的 pointToLightEC 是“光源指向像素”的向量,因此计算法线点乘时需要将方向取反:
1 2 3 4 5 6 7 8 9
| vec3 pointToLightEC = positionEC - lightPositionEC; float distanceToLight = length(pointToLightEC); vec3 lightToPoint = normalize(pointToLightEC);
float NdotL = clamp( dot(-normalEC, lightToPoint), 0.0, 1.0 );
|
它等价于常见的:
1
| 漫反射强度 = max(表面法线 · 表面指向光源的方向, 0)
|
表面正对光源时结果接近 1,背对光源时结果为 0。
5. 计算距离衰减
点光源越远越暗。这里使用平方反比衰减,并利用光照半径让光照在边界处平滑降为零:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| float getRangeAttenuation(float range, float distanceToLight) { float d = max(distanceToLight, 0.001);
if (range <= 0.0) { return 1.0 / (d * d); }
float rangeFactor = clamp( 1.0 - pow(d / range, 4.0), 0.0, 1.0 );
return rangeFactor / (d * d); }
|
单盏灯的光照贡献可以表示为:
1
| 光照贡献 = 光源颜色 × 光源强度 × 距离衰减 × N·L × 阴影可见度
|
其中半径既限定了计算范围,也参与衰减计算。
6. 点光源为什么需要立方体阴影贴图
普通相机只有一个观察方向,二维阴影贴图也只能记录一个方向的遮挡关系。但点光源会向四周发光,需要记录光源周围六个方向的深度:
六个方向共同组成一张立方体阴影贴图。对于当前像素,只要根据“光源到像素”的方向采样立方体贴图,再比较两者深度,就能判断中间是否存在建筑物遮挡。
判断逻辑如下:
1 2 3 4 5
| 阴影贴图记录的最近深度 < 当前像素到光源的深度 ↓ 中间存在遮挡物 ↓ 不叠加灯光
|
二、创建点光源对象
先封装一个 PointLight 类,保存光源位置、颜色、强度和半径:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const DEFAULT_STYLE = { color: Cesium.Color.WHITE, intensity: 10, radius: 1000 };
class PointLight { constructor(options = {}) { this.id = Cesium.createGuid(); this.style = { ...DEFAULT_STYLE, ...options.style };
this.position = Array.isArray(options.position) ? Cesium.Cartesian3.fromDegrees(...options.position) : options.position;
this.color = typeof this.style.color === 'string' ? Cesium.Color.fromCssColorString(this.style.color) : this.style.color;
this.scene = null; this.lightCamera = null; this.shadowMap = null; } }
|
其中:
position 使用世界坐标;
color 保存归一化后的 RGB 颜色;
intensity 控制亮度;
radius 控制光照和阴影的最大作用范围。
三、创建点光源阴影贴图
每盏点光源都需要一个虚拟相机。这个相机不负责显示画面,而是作为阴影贴图的光源视角:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| function createPointShadowMap(light, scene) { const lightCamera = new Cesium.Camera(scene); lightCamera.position = light.position;
lightCamera.up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( light.position, new Cesium.Cartesian3() );
const shadowMap = new Cesium.ShadowMap({ context: scene.context, lightCamera, enabled: true, isPointLight: true, isSpotLight: false, cascadesEnabled: false, numberOfCascades: 1, pointLightRadius: light.style.radius, fromLightSource: false, darkness: 1.0 });
light.lightCamera = lightCamera; light.shadowMap = shadowMap; }
|
这里最关键的参数是:
Cesium 会按点光源方式生成立方体阴影贴图。光源移动时,需要同步更新虚拟相机的位置:
1
| light.lightCamera.position = light.position;
|
如果修改了点光源半径,通常需要重新创建阴影贴图,使 pointLightRadius 与新半径保持一致。
四、把阴影贴图接入 Cesium 每帧渲染流程
只创建 ShadowMap 并不会让 Cesium 自动在每一帧使用它。可以创建一个轻量级 Primitive,在其 update 阶段把所有有效阴影贴图加入当前帧的 frameState.shadowMaps:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| class ShadowMapPrimitive { constructor(lightCollection) { this.lightCollection = lightCollection; this.destroyed = false; }
update(frameState) { if (this.destroyed) return;
for (const light of this.lightCollection.lights) { if (!light.shadowMap) continue;
light.lightCamera.position = light.position; frameState.shadowMaps.push(light.shadowMap); } }
isDestroyed() { return this.destroyed; }
destroy() { this.destroyed = true; return undefined; } }
const shadowMapPrimitive = new ShadowMapPrimitive(lightCollection);
viewer.scene.primitives.add(shadowMapPrimitive);
|
之所以要在 Primitive.update 中添加,是因为 Cesium 每帧都会重新组织 FrameState。在这里注入,阴影贴图才能被当前帧的阴影渲染流程消费。
同时还要让需要参与遮挡的 3D Tiles 开启阴影:
1
| tileset.shadows = Cesium.ShadowMode.ENABLED;
|
五、向后处理着色器传递光源数据
后处理阶段需要以下数据:
1 2 3 4 5
| const int LightNum = 3;
uniform vec4 lightPositionEC[LightNum]; uniform vec3 lightColor[LightNum]; uniform float intensity[LightNum];
|
lightPositionEC 的四个分量可以打包为:
光源原始位置是世界坐标,而后处理重建出来的像素位置是观察坐标,因此需要转换坐标系:
1 2 3 4 5 6 7 8 9 10 11 12
| const positionEC = Cesium.Matrix4.multiplyByPoint( viewer.scene.camera.viewMatrix, light.position, new Cesium.Cartesian3() );
const packedPosition = new Cesium.Cartesian4( positionEC.x, positionEC.y, positionEC.z, light.style.radius );
|
如果已经创建阴影贴图,可以优先复用阴影贴图当帧计算出的光源观察坐标,保证后处理光照位置与阴影贴图使用的位置完全一致:
1
| const positionEC = light.shadowMap?._lightPositionEC;
|
后处理的 uniforms 使用函数返回值,Cesium 会在渲染时读取最新数据,因此移动灯光和相机后不需要手动重建所有数值:
1 2 3 4 5 6
| const uniforms = { baseBrightness: () => baseBrightness, lightPositionEC: () => collectLightPositions(), lightColor: () => collectLightColors(), intensity: () => collectLightIntensity() };
|
六、在着色器中判断阴影
每盏点光源对应一个 samplerCube:
1 2
| uniform samplerCube lightShadowMapCube0; uniform samplerCube lightShadowMapCube1;
|
对于当前像素,先计算像素到光源的距离,并将它除以光照半径,得到与点光源阴影贴图一致的归一化深度:
1 2 3 4 5 6
| vec3 pointToLightEC = positionEC - lightPositionEC.xyz; float distanceToLight = length(pointToLightEC);
ShadowParameters params; params.depth = distanceToLight / lightPositionEC.w; params.depthBias = depthBias;
|
再把查询方向从观察坐标旋转到立方体阴影贴图需要的方向空间:
1 2 3
| vec3 direction = normalize(pointToLightEC); params.texCoordsCube = czm_inverseViewRotation * direction;
|
最后进行深度比较:
1 2 3 4 5
| float visibility = czm_shadowDepthCompare( lightShadowMapCube0, params.texCoordsCube, params.depth - params.depthBias );
|
当 visibility 为 1.0 时,说明当前像素能看到光源,可以叠加光照;为 0.0 时说明中间存在遮挡。
深度偏移量 depthBias 用于缓解阴影痤疮。如果偏移太小,建筑表面容易错误地遮挡自身;偏移太大,又会产生阴影与物体分离的问题,需要根据场景尺度调节。
七、累加多盏灯并合成最终颜色
单盏灯的核心计算可以整理为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| vec3 calculatePointLight( vec3 normalEC, vec3 positionEC, vec4 lightPositionRadius, vec3 color, float lightIntensity, float visibility ) { vec3 pointToLight = positionEC - lightPositionRadius.xyz;
float distanceToLight = length(pointToLight);
if (distanceToLight > lightPositionRadius.w || visibility <= 0.0) { return vec3(0.0); }
vec3 lightToPoint = normalize(pointToLight);
float diffuse = clamp( dot(-normalEC, lightToPoint), 0.0, 1.0 );
float attenuation = getRangeAttenuation( lightPositionRadius.w, distanceToLight );
return color * lightIntensity * attenuation * diffuse * visibility; }
|
多盏灯逐个计算并累加:
1 2 3 4 5
| vec3 totalLight = vec3(0.0);
totalLight += calculatePointLight(); totalLight += calculatePointLight(); totalLight += calculatePointLight();
|
最后与原场景颜色合成:
1 2 3 4
| out_FragColor = vec4( sceneColor.rgb * baseBrightness + totalLight, sceneColor.a );
|
baseBrightness 可以控制原始场景亮度。例如夜景中可以适当降低原场景亮度,再突出局部灯光。
着色器中的灯光数量通常是编译期常量,而每盏灯又需要独立的 samplerCube。因此可以在添加或删除灯光时,根据当前灯光数量动态拼接着色器代码,并重新创建后处理阶段:
1 2 3 4
| const fragmentShader = shaderTemplate .replace('LIGHT_COUNT', String(lights.length)) .replace('SHADOW_UNIFORMS', generatedUniformCode) .replace('LIGHT_CALCULATIONS', generatedLightCode);
|
这比在每一帧重建着色器更合理:灯光位置、颜色和强度通过 uniform 更新,只有灯光数量变化时才重新编译着色器。
八、创建和管理多盏点光源
使用集合统一管理光源和后处理阶段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| class PointLightCollection { constructor(viewer, options = {}) { this.viewer = viewer; this.scene = viewer.scene; this.lights = []; this.enableShadow = options.enableShadow !== false; this.baseBrightness = options.baseBrightness ?? 1.0; this.postProcessStage = null; }
add(options) { const light = new PointLight(options);
if (this.enableShadow) { createPointShadowMap(light, this.scene); }
this.lights.push(light); this.updatePostProcess(); return light; }
remove(light) { const index = this.lights.indexOf(light); if (index === -1) return;
this.lights.splice(index, 1); light.shadowMap?.destroy(); this.updatePostProcess(); }
clear() { for (const light of this.lights) { light.shadowMap?.destroy(); }
this.lights.length = 0; this.clearPostProcess(); } }
|
添加一盏黄色点光源:
1 2 3 4 5 6 7 8 9 10 11 12 13
| const lightCollection = new PointLightCollection(viewer, { enableShadow: true, baseBrightness: 0.6 });
const yellowLight = lightCollection.add({ position: [108.9594, 34.2196, 40], style: { color: '#e3e238', intensity: 200, radius: 1000 } });
|
动态修改位置时,同步更新光源对象即可:
1 2 3 4 5 6 7 8
| yellowLight.position = Cesium.Cartesian3.fromDegrees( 108.9601, 34.2198, 50 );
yellowLight.lightCamera.position = yellowLight.position;
|
九、实现跟随鼠标移动的点光源
使用 pickPosition 获取鼠标指向的模型或地形位置,然后把光源沿高度方向抬高一段距离,避免灯光落在模型内部:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const handler = new Cesium.ScreenSpaceEventHandler( viewer.scene.canvas );
handler.setInputAction((movement) => { const pickedPosition = viewer.scene.pickPosition( movement.endPosition );
if (!Cesium.defined(pickedPosition)) return;
const cartographic = Cesium.Cartographic.fromCartesian(pickedPosition);
mouseLight.position = Cesium.Cartesian3.fromRadians( cartographic.longitude, cartographic.latitude, cartographic.height + 10 );
mouseLight.lightCamera.position = mouseLight.position; }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
如果要显示灯的位置,可以使用 PointGraphics 作为标记。不要直接放置一个会写入阴影贴图的实体球体,否则实体球可能把光源包住,导致光源周围都被误判为阴影。