Cesium 实现点光源

Cesium 本身主要提供太阳光、环境光以及基于材质的场景渲染,并没有直接提供一个能够照亮 3D Tiles 的通用点光源接口。要在不修改 3D Tiles 原始材质的前提下实现动态点光源,可以使用一套“屏幕空间光照 + 点光源阴影贴图”的方案。

本文实现的效果包括:

  • 支持动态添加多个点光源;
  • 支持设置光源位置、颜色、强度和作用半径;
  • 光照强度会随距离衰减;
  • 建筑物能够遮挡灯光并产生阴影;
  • 光源可以跟随鼠标在模型表面移动;
  • 不需要修改 3D Tiles 的原始材质。

多盏点光源同时作用在场景里:红色光源照着建筑,黄色光源照着树冠,白色光源跟随鼠标落在屋顶

本文使用的是兰伯特漫反射、距离衰减和阴影,并不是包含金属度、粗糙度和高光计算的完整 PBR 光照。

一、实现原理

整体思路是:先在后处理着色器里读取场景的深度纹理,根据深度恢复当前像素对应的三维位置,然后再利用周围像素的深度估算出表面法线。

有了像素位置和法线之后,我就能算出当前像素到光源的方向和距离,通过法线和光照方向点乘计算漫反射,再结合光源颜色、强度以及距离衰减,算出这个点光源对当前像素的光照贡献。

另外我还给每个点光源创建了一个 Cesium 的阴影贴图。因为点光源是向四周发光的,所以这里用的是立方体阴影贴图,通过比较当前像素到光源的距离和阴影贴图里记录的深度,判断中间有没有建筑物遮挡。被遮挡的话,这个像素就不叠加灯光。

最后把所有点光源的光照结果累加到 Cesium 原始场景颜色上,这样就实现了支持多个动态点光源、距离衰减和建筑遮挡阴影的效果

整体方案由两部分组成:

  1. 使用 PostProcessStage 在后处理阶段计算点光源对屏幕像素的光照贡献;
  2. 使用 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
+X、-X、+Y、-Y、+Z、-Z

六个方向共同组成一张立方体阴影贴图。对于当前像素,只要根据“光源到像素”的方向采样立方体贴图,再比较两者深度,就能判断中间是否存在建筑物遮挡。

判断逻辑如下:

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;
}

这里最关键的参数是:

1
isPointLight: true

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
xyz:光源在观察坐标系中的位置
w:光照半径

光源原始位置是世界坐标,而后处理重建出来的像素位置是观察坐标,因此需要转换坐标系:

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(/* 第 1 盏灯 */);
totalLight += calculatePointLight(/* 第 2 盏灯 */);
totalLight += calculatePointLight(/* 第 3 盏灯 */);

最后与原场景颜色合成:

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 作为标记。不要直接放置一个会写入阴影贴图的实体球体,否则实体球可能把光源包住,导致光源周围都被误判为阴影。

作者

wangyawei

发布于

2026-09-18

更新于

2026-09-18

许可协议