
目录
-
[几何形状与纹理坐标 (UV) 概述](#几何形状与纹理坐标 (UV) 概述 "#%E5%87%A0%E4%BD%95%E5%BD%A2%E7%8A%B6%E4%B8%8E%E7%BA%B9%E7%90%86%E5%9D%90%E6%A0%87-uv-%E6%A6%82%E8%BF%B0")
-
- [模型空间 vs 世界空间](#模型空间 vs 世界空间 "#%E6%A8%A1%E5%9E%8B%E7%A9%BA%E9%97%B4-vs-%E4%B8%96%E7%95%8C%E7%A9%BA%E9%97%B4")
- [UV 坐标的作用](#UV 坐标的作用 "#uv-%E5%9D%90%E6%A0%87%E7%9A%84%E4%BD%9C%E7%94%A8")
- [getWindowPattern 函数详解](#getWindowPattern 函数详解 "#getwindowpattern-%E5%87%BD%E6%95%B0%E8%AF%A6%E8%A7%A3")
- [示意图:UV ×4 网格与窗户中心区域](#示意图:UV ×4 网格与窗户中心区域 "#%E7%A4%BA%E6%84%8F%E5%9B%BEuv-4-%E7%BD%91%E6%A0%BC%E4%B8%8E%E7%AA%97%E6%88%B7%E4%B8%AD%E5%BF%83%E5%8C%BA%E5%9F%9F")
-
- [什么是 Emissive?](#什么是 Emissive? "#%E4%BB%80%E4%B9%88%E6%98%AF-emissive")
- [在 PBR 材质中的作用](#在 PBR 材质中的作用 "#%E5%9C%A8-pbr-%E6%9D%90%E8%B4%A8%E4%B8%AD%E7%9A%84%E4%BD%9C%E7%94%A8")
- [如何在 Shader 中叠加 Emissive](#如何在 Shader 中叠加 Emissive "#%E5%A6%82%E4%BD%95%E5%9C%A8-shader-%E4%B8%AD%E5%8F%A0%E5%8A%A0-emissive")
-
- 场景与相机设置
- 建筑模型与材质创建
- [Shader 修改:onBeforeCompile 钩子解析](#Shader 修改:onBeforeCompile 钩子解析 "#shader-%E4%BF%AE%E6%94%B9onbeforecompile-%E9%92%A9%E5%AD%90%E8%A7%A3%E6%9E%90")
- 点光源放置:贴近窗户外部
- 鼠标交互与渲染循环
引言
在 3D 场景中,如果想模拟夜晚城市中高楼的灯火效果,"窗户发光"是最常见的视觉元素之一。一个直观的方法是:
- 用 自发光(emissive) 在墙体上直接画出发光的窗户格。
- 在 几何外部贴近窗户 放置点光源,制造局部高光与阴影对比,增强真实感。
本文将以一个完整的 Three.js 示例为蓝本,讲解窗户位置的计算 、Emissive 实现 及点光源放置 的数学和图形学原理,并配以示意图帮助理解。
项目整体架构概览
在阅读下文之前,先快速了解示例中各部分的关系:
-
几何体 (BoxGeometry)
-
大小为
2×4×2
,中心在原点。 -
由于放在
(0,0,0)
,在模型空间内的顶点坐标:- X ∈ [−1, +1]
- Y ∈ [−2, +2]
- Z ∈ [−1, +1]
-
-
纹理坐标 (UV)
- BoxGeometry 自带每个面的 UV 都是 [0,1] 范围,覆盖整个面。
- 我们在 Shader 中通过修改 UV 或用 UV 乘系数来做"网格分割"。
-
材质 (MeshStandardMaterial + onBeforeCompile)
-
创建 PBR 材质,原本会根据场景灯光、材质参数(roughness/metalness)进行标准渲染。
-
通过
onBeforeCompile
钩子,在生成 Shader 时插入自定义代码:- 计算哪些像素属于"窗户格",在这些位置叠加自发光颜色。
- 保留 PBR 的光照计算,确保墙体在点光照射下产生高光和阴影。
-
-
点光源 (PointLight)
- 原本我们随机在盒子内部放光源,但这样看不到从窗户透出的效果。
- 改为 放在盒子外侧、紧贴窗户面,让点光直接打亮墙体的"窗户区域"与其周围。
下图是整体逻辑流程示意(图中流程为示意,实际在 Shader 和 Three.js 代码中交互):
scss
几何 (模型空间) ─┐
├─> Vertex Shader(计算 vPosition, vWorldNormal 等 varying)
Shader onBeforeCompile ──┼─> Fragment Shader(判断窗户区域并叠加 Emissive,然后执行 PBR 光照)
点光源 (世界空间) ────┘
几何形状与纹理坐标 (UV) 概述
在做窗户效果时,要理解两个核心概念:模型空间下的坐标 与 对应的 UV 纹理坐标。
-
模型空间 (Model Space)
-
当你
new THREE.BoxGeometry(2, 4, 2)
,顶点坐标如下:- X 轴方向长度为 2 → 半宽 1,因此 X ∈ [−1, +1]
- Y 轴方向长度为 4 → 半高 2,因此 Y ∈ [−2, +2]
- Z 轴方向长度为 2 → 半深 1,因此 Z ∈ [−1, +1]
-
片元着色器中用
vPosition = position;
把"模型空间坐标"传到片元阶段。此坐标用于判断"当前片元是否在侧面"以及"窗户高度"。
-
-
纹理坐标 (UV)
-
BoxGeometry 自带的 UV 坐标对每个面都是 [0,1] × [0,1],例如侧面某一点的 UV 可能是
(u, v)
∈ [0,1]。 -
在 Shader 中,我们用
vUv = uv;
把 UV 传到片元阶段。UV 主要用来"均匀地"在一个面上做网格切分:- 如果把
vUv * 4.0
,则相当于将 [0,1] 区间划分成 4×4 的网格,每个单元格的坐标范围 [i, i+1],i=0,1,2,3。
- 如果把
-
之后再用
fract(vUv * 4.0)
得到一个 [0,1] × [0,1] 的"单元格内部坐标",进而裁定中心区域(例如0.5±windowSize
)作为窗口。
-
小结:我们在顶点阶段获得"模型空间位置"与"UV",在片元阶段同时使用它们:
vPosition
用于判断"哪些面是侧面"------只在侧面做窗户与 PBR 光照。vUv
用于"网格切分 + 判断窗户格子" → 只在这些小格中心区域叠加自发光。
窗户位置的计算原理
模型空间 vs 世界空间
- 在顶点着色器阶段,将
position
(模型空间下的顶点坐标)传给vPosition
。无论建筑如何旋转或相机如何移动,vPosition
始终代表"在建筑本地坐标系中的点位置",保证窗户逻辑稳定一致。 - 世界空间下的法线
vWorldNormal
用于标准 PBR 光照计算。如果需要在侧面发光以外还考虑光影,就要依赖它。
UV 坐标的作用
- UV 坐标
vUv
∈ [0,1] × [0,1],表示当前片元在该面的纹理比例位置。 - 我们把
vUv * 4.0
,映射到 [0,4] × [0,4],然后对它做fract()
,得到一个"网格单元内的局部坐标" ∈ [0,1] × [0,1]。 - 这相当于用 4×4 个相同尺寸的单元,在每个单元中心画一个小窗户。
getWindowPattern 函数详解
以下是示例代码中 getWindowPattern
的核心逻辑:
glsl
float getWindowPattern(vec2 uvScaled) {
// uvScaled ∈ [0,4],通过 fract() 取小数部分得到当前单元内位置 ∈ [0,1]
vec2 grid = fract(uvScaled);
// windowSize 定义为"小窗户半边长度"(单元大小为 1 时)
// step(a, x) = (x >= a ? 1.0 : 0.0)
// X 方向:判断 grid.x 是否在 [0.5 - windowSize, 0.5 + windowSize] 区间
float windowX =
step(0.5 - windowSize, grid.x) * // 当 grid.x ≥ 0.5 - windowSize 时,值为 1
(1.0 - step(0.5 + windowSize, grid.x)); // 当 grid.x < 0.5 + windowSize 时,值为 1
// Y 方向同理
float windowY =
step(0.5 - windowSize, grid.y) *
(1.0 - step(0.5 + windowSize, grid.y));
// 只有当两个方向都满足,才返回 1.0,否则返回 0.0
return windowX * windowY;
}
- 输入
uvScaled = vUv * 4.0
:将原始 UV 从 [0,1] 拉伸到 [0,4],代表 4×4 格子。 fract(uvScaled)
:抛弃整数部分,得到每个单元内的局部坐标 ∈ [0,1]。step(0.5 - windowSize, grid.x)
:当grid.x ≥ 0.5 - windowSize
时,这值为 1,表示在单元中心左侧windowSize
以外。1.0 - step(0.5 + windowSize, grid.x)
:当grid.x < 0.5 + windowSize
时,这值为 1,表示在单元中心右侧windowSize
以内。- 合二为一:
grid.x ∈ [0.5 - windowSize, 0.5 + windowSize]
→ 这个区间宽度为2 * windowSize
。 windowX * windowY
只有在"单元中心的正方形区域"才等于 1,其他位置为 0。
举例数值:
- 当
windowSize = 0.12
时,单元格大小在 Grid 坐标系下相当于 1。 - 因此窗户 (x, y) 区域宽度为
2 * 0.12 = 0.24
,位于单元中心的(0.5, 0.5)
处。
示例图:UV ×4 网格与窗户中心区域
下图展示了一个 4×4 网格(对应 uv * 4
的结果),以及在每个单元中心绘制的窗户示意(黄色半透明方块)。其中,每个小方块对应 grid ∈ [0.5 - 0.12, 0.5 + 0.12]
。
图 1:窗户网格示意图
- 整个大正方形代表
(vUv * 4.0)
范围[0,4] × [0,4]
。- 黑色网格线把它切分成 4×4。
- 每个单元内部,由
getWindowPattern
判断中心0.5 ± windowSize
区域(图中黄色)为窗户格,只有这些区域会发光。
自发光(Emissive)原理详解
什么是 Emissive?
-
在 PBR(Physically Based Rendering)材质中,除了漫反射 (diffuse) 及镜面反射 (specular) 之外,还有一个称为 Emissive(自发光)的属性。
-
Emissive 表示物体自身发出的光,这种光不受外部光照影响,始终在最终渲染中叠加。
-
简而言之:
- 如果一个片元(像素)的 Emissive 值非零,那么它会"自己发光",即使场景没有光源,也能看到它在亮。
在 PBR 材质中的作用
-
在 Fragment Shader 中,Three.js 会计算
totalEmissiveRadiance
-
这是一个累加了材质
emissive
颜色的变量,在执行完所有的漫反射 + 镜面反射 + 阴影 + 环境光后,最终将totalEmissiveRadiance
添加到输出颜色。 -
伪代码示例如下:
glslvec3 totalEmissiveRadiance = vec3(0.0); #include <emissivemap_fragment> // 处理材质的 emissiveMap 或 emissiveUniform // ... 其他 PBR 计算(漫反射、镜面反射、阴影、环境光等) gl_FragColor = vec4(outgoingLight + totalEmissiveRadiance, alpha);
-
只要我们在
#include <emissivemap_fragment>
之后增加:glsltotalEmissiveRadiance += windowColor * glowIntensity;
就能让"窗户像素"产生自发光效果。
-
-
Emissive 不会产生真实光照
- Emissive 只是在渲染输出时给像素加亮,不会去影响周围其他物体,也不会产生阴影。
- 如果需要让窗户"发光"同时影响环境照明,需要额外放置点光或其他光源来照亮周边。
如何在 Shader 中叠加 Emissive
示例片元 Shader 中最关键的一段是:
glsl
#include <emissivemap_fragment>
// 用 vPosition 判断,只在四个侧面显示窗户
float eps = 0.01;
bool isSideX = abs(vPosition.x - 1.0) < eps || abs(vPosition.x + 1.0) < eps;
bool isSideZ = abs(vPosition.z - 1.0) < eps || abs(vPosition.z + 1.0) < eps;
bool isSide = isSideX || isSideZ;
if (isSide) {
float windowMask = getWindowPattern(vUv * 4.0); // 0 或 1
if (windowMask > 0.5) {
float height = clamp((vPosition.y + 2.0) / 4.0, 0.0, 1.0);
float glowIntensity = height * windowEmission;
float flicker = 0.9 + 0.1 * sin(time * 3.0
+ vWorldPosition.x * 10.0
+ vWorldPosition.z * 5.0);
glowIntensity *= flicker;
totalEmissiveRadiance += windowColor * glowIntensity;
}
}
isSide
:判断当前片元是否属于侧面,避免顶面/底面出现窗户。getWindowPattern(vUv * 4.0)
:如果返回1.0
,说明此处是某个小网格中心,就画窗户发光。height = (vPosition.y + 2) / 4
:把 Y 从 [−2,+2] 映射到 [0,1],让越高的窗户发光更强。flicker
:根据sin(time...)
让窗户随机闪烁,配合每个窗的世界坐标 (vWorldPosition.x, z
) 让闪烁有差异性。totalEmissiveRadiance += windowColor * glowIntensity
:叠加到最终输出,实现自发光。
完整代码拆解与流程说明
下面按顺序拆解示例中的关键代码段,带你逐步理解每一步的原理。
场景与相机设置
js
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x000000, 10, 50);
const camera = new THREE.PerspectiveCamera(
75, window.innerWidth / window.innerHeight, 0.1, 1000
);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x000000);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById("container").appendChild(renderer.domElement);
- 场景雾化 :
new THREE.Fog(0x000000, 10, 50)
让远处建筑逐渐融入黑夜,增加空间感。 - 相机:视锥近平面 0.1、远平面 1000。
- 渲染器:启用抗锯齿、阴影并选用软阴影模式。
建筑模型与材质创建
js
const customUniforms = {
windowColor: { value: new THREE.Color(0xfff3e0) }, // 窗户发光颜色
windowEmission:{ value: 2.0 }, // 发光强度
windowBloom: { value: 1.5 }, // (可用于后处理 bloom)
windowSize: { value: 0.12 }, // 单个窗户半边宽度(在 UV 单元内)
windowSpacing: { value: 0.25 },
time: { value: 0 }, // 全局时间,用于抖动
};
const buildingMaterial = new THREE.MeshStandardMaterial({
color: 0x1a1a1a, // 深灰色墙体
roughness: 0.8,
metalness: 0.2,
});
windowSize
与windowSpacing
用于getWindowPattern
函数中,控制窗户在网格内的大小与间距。- 稍后我们会对
buildingMaterial
使用onBeforeCompile
钩子,插入自定义发光逻辑。
js
const buildingGeometry = new THREE.BoxGeometry(2, 4, 2);
const building = new THREE.Mesh(buildingGeometry, buildingMaterial);
building.castShadow = true;
building.receiveShadow = true;
scene.add(building);
- 建筑物使用
BoxGeometry(2,4,2)
,半宽 1、半高 2、半深 1。
Shader 修改:onBeforeCompile 钩子解析
js
buildingMaterial.onBeforeCompile = (shader) => {
// 1. 添加自定义 uniforms
Object.assign(shader.uniforms, customUniforms);
// --- 顶点着色器部分 ---
shader.vertexShader =
`
varying vec2 vUv;
varying vec3 vPosition;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
`#include <worldpos_vertex>`,
`
#include <worldpos_vertex>
vUv = uv; // 传递 UV 坐标
vPosition = position; // 传递模型空间坐标
vWorldPosition = worldPosition.xyz; // 传递世界空间坐标
vWorldNormal = normalize(mat3(modelMatrix) * normal); // 传递世界法线
`
);
// --- 片元着色器部分 (插入自发光逻辑) ---
const fragmentDeclarations = `
uniform vec3 windowColor;
uniform float windowEmission;
uniform float windowBloom;
uniform float windowSize;
uniform float windowSpacing;
uniform float time;
varying vec2 vUv;
varying vec3 vPosition;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
float getWindowPattern(vec2 uvScaled) {
vec2 grid = fract(uvScaled);
float windowX = step(0.5 - windowSize, grid.x) * (1.0 - step(0.5 + windowSize, grid.x));
float windowY = step(0.5 - windowSize, grid.y) * (1.0 - step(0.5 + windowSize, grid.y));
return windowX * windowY;
}
`;
const fragmentLogic = `
#include <emissivemap_fragment>
// 用 vPosition 判断,只在四个侧面显示窗户
float eps = 0.01;
bool isSideX = abs(vPosition.x - 1.0) < eps || abs(vPosition.x + 1.0) < eps;
bool isSideZ = abs(vPosition.z - 1.0) < eps || abs(vPosition.z + 1.0) < eps;
bool isSide = isSideX || isSideZ;
if (isSide) {
float windowMask = getWindowPattern(vUv * 4.0);
if (windowMask > 0.5) {
// 按高度做梯度:越高的窗户发光越强
float height = clamp((vPosition.y + 2.0) / 4.0, 0.0, 1.0);
float glowIntensity = height * windowEmission;
// 抖动效果
float flicker = 0.9 + 0.1 * sin(time * 3.0
+ vWorldPosition.x * 10.0
+ vWorldPosition.z * 5.0);
glowIntensity *= flicker;
totalEmissiveRadiance += windowColor * glowIntensity;
}
}
`;
let finalFragmentShader = shader.fragmentShader;
// 将自定义逻辑插入到 <emissivemap_fragment> 之后
finalFragmentShader = finalFragmentShader.replace(
"#include <emissivemap_fragment>",
fragmentLogic
);
// 在最前面添加 uniform、varying 和 getWindowPattern 函数声明
finalFragmentShader = fragmentDeclarations + finalFragmentShader;
shader.fragmentShader = finalFragmentShader;
};
关键解析:
- 在顶点阶段 插入
vUv
,vPosition
,vWorldPosition
,vWorldNormal
,方便片元阶段使用。 - 在片元阶段 先声明
getWindowPattern
函数和相关uniform/varying
,随后用replace
将逻辑粘贴到#include <emissivemap_fragment>
之后。 isSide
:判断是否在侧面;仅在侧面执行窗户发光,否则跳过。getWindowPattern(vUv * 4.0)
判断当前 UV 坐标是否落在网格中心的窗户区域。height
控制窗口发光梯度;flicker
通过三角函数产生闪烁。totalEmissiveRadiance += windowColor * glowIntensity
将自发光累加到最终片元颜色。
点光源放置:贴近窗户外部
将 8 盏点光源都放在四面墙外、紧贴窗户位置,示例代码如下:
js
const windowLights = [];
for (let i = 0; i < 8; i++) {
const light = new THREE.PointLight(0xfff3e0, 0.3, 10, 2);
// 随机选一个侧面:0~3 分别对应 +X、-X、+Z、-Z
const faceIndex = Math.floor(Math.random() * 4);
let x = 0;
let y = -1 + Math.random() * 3; // y ∈ [-1, +2]
let z = 0;
if (faceIndex === 0) {
// 右侧墙 (x = +1.1),z 在 [-0.9, +0.9]
x = 1.1;
z = (Math.random() - 0.5) * 1.8;
} else if (faceIndex === 1) {
// 左侧墙 (x = -1.1)
x = -1.1;
z = (Math.random() - 0.5) * 1.8;
} else if (faceIndex === 2) {
// 前侧墙 (z = +1.1)
z = 1.1;
x = (Math.random() - 0.5) * 1.8;
} else {
// 后侧墙 (z = -1.1)
z = -1.1;
x = (Math.random() - 0.5) * 1.8;
}
light.position.set(x, y, z);
light.castShadow = true;
light.shadow.mapSize.width = 256;
light.shadow.mapSize.height = 256;
light.shadow.camera.near = 0.1;
light.shadow.camera.far = 15;
scene.add(light);
windowLights.push(light);
// 可视化光源位置(黄色小球)
const helper = new THREE.PointLightHelper(light, 0.1, 0xffff00);
scene.add(helper);
}
-
侧面坐标
- 盒子半宽为 1 → 侧面平面上
X = ±1
或Z = ±1
。 - 放在外部则用
X = ±1.1
或Z = ±1.1
,离墙体稍微 0.1 单位。
- 盒子半宽为 1 → 侧面平面上
-
随机横向位置
- 如果打亮右侧墙
X = +1.1
,就让Z ∈ [−0.9, +0.9]
,这样光源沿墙面深度随机分布。 - 同理,针对左、前、后四个面进行随机位置分配。
- 如果打亮右侧墙
-
随机高度
Y ∈ [−1, +2]
,覆盖中低层到高层窗户。- 保证窗户能够均匀受光。
这样做的好处:
- 点光源直接打在窗户面外,能明显照亮窗户和周围墙体。
- 无需在几何内部放光,也不用开洞,让实现更简单。
鼠标交互与渲染循环
js
camera.position.set(4, 2, 6);
camera.lookAt(0, 0, 0);
let mouseDown = false, mouseX = 0, mouseY = 0;
let targetRotationX = 0, targetRotationY = 0;
let currentRotationX = 0, currentRotationY = 0;
renderer.domElement.addEventListener("mousedown", (e) => {
mouseDown = true;
mouseX = e.clientX;
mouseY = e.clientY;
});
document.addEventListener("mouseup", () => (mouseDown = false));
document.addEventListener("mousemove", (e) => {
if (!mouseDown) return;
targetRotationY += (e.clientX - mouseX) * 0.01;
targetRotationX += (e.clientY - mouseY) * 0.01;
mouseX = e.clientX;
mouseY = e.clientY;
});
function animate() {
requestAnimationFrame(animate);
const time = Date.now() * 0.001;
customUniforms.time.value = time;
// 缓动插值旋转
currentRotationX += (targetRotationX - currentRotationX) * 0.1;
currentRotationY += (targetRotationY - currentRotationY) * 0.1;
const quaternionX = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0),
currentRotationX
);
const quaternionY = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
currentRotationY
);
building.quaternion.copy(quaternionY.multiply(quaternionX));
// 更新点光强度,让它们随时间闪烁
windowLights.forEach((light, index) => {
light.intensity =
customUniforms.windowEmission.value *
0.15 *
(0.8 + 0.2 * Math.sin(time * 2 + index));
});
renderer.render(scene, camera);
}
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
animate();
- 旋转逻辑 :鼠标拖拽实时改变目标旋转
targetRotationX/Y
,用线性插值(target - current) * 0.1
使旋转更平滑。 - 动态闪烁 :每帧根据
sin(time*2 + index)
改变light.intensity
,与窗口自发光flicker
保持呼应,增强夜景生动感。
示例效果与调试建议
-
查看侧面灯光分布
- 运行后,你会看到墙面被划分为 4×4 网格,每个网格中心所属窗户实际发光。
- 8 盏点光贴近墙面分布,它们会投射暖色光斑,形成局部高光。
-
调节窗户参数
- "窗户发光强度"(windowEmission)越大,自发光越亮。
- "窗户光晕强度"(windowBloom)可在后处理 Pass 中调节更柔和的泛光。
-
调节点光属性
- 如果某些窗户不够亮,可增大该面上点光的
intensity
或distance
,让光覆盖更广。 - 用
SpotLight
代替PointLight
可以让光束更聚焦,专门打到单个窗格。
- 如果某些窗户不够亮,可增大该面上点光的
-
旋转观察
- 拖拽时可看到点光在不同角度如何照亮侧面墙体,Emissive 与 PBR 光照叠加后效果更真实。
- 窗户自发光逻辑基于
vPosition
(模型空间),建筑旋转时,窗户位置始终正确。
总结与延伸思考
本文结合示例代码,详细介绍了如何在 Three.js 中实现"夜晚建筑发光窗户"效果,重点讲解了: 1. 模型空间 (vPosition) 与 UV 坐标 (vUv) 的配合 ,用于精确定位窗户格子所在。 2. getWindowPattern 的数学原理:通过 fract
+ step
函数,在 4×4 网格中心区域画出小窗。 3. 自发光 (Emissive) 在 PBR 材质中的使用:向 totalEmissiveRadiance
累加发光颜色,用于模拟窗户发亮。 4. 点光源的放置:改为贴近建筑外侧,增强局部高光与阴影对比,让画面更具立体感。
延伸思考
-
CSG 开窗 vs Shader 剔除
- 本文示例没有在几何层面做布尔运算真实挖洞,若需要实现从内部望出去,可以使用 CSG 库对
BoxGeometry
进行"减法"操作挖出窗户。 - 也可在 Shader 里对窗户区域做
discard
,让它们透明,但这会影响深度与阴影计算,需谨慎使用。
- 本文示例没有在几何层面做布尔运算真实挖洞,若需要实现从内部望出去,可以使用 CSG 库对
-
Bloom 后期泛光
- 若想获得更柔和的夜景光晕,可将 Emissive 通道单独渲染到一个 RenderTarget,再用
UnrealBloomPass
做后处理模糊。
- 若想获得更柔和的夜景光晕,可将 Emissive 通道单独渲染到一个 RenderTarget,再用
-
真实玻璃折射
- 如果追求更高真实感,可在每个窗户面上使用
MeshPhysicalMaterial
并启用transmission
与半透明特性,让内部光线真实折射与反射。
- 如果追求更高真实感,可在每个窗户面上使用
-
建筑群场景优化
- 本示例仅为单栋建筑。若渲染多栋建筑,可考虑
InstancedMesh
或几何合并来提升性能,并将窗户 Emissive 烘焙到贴图,再配合实时点光或环境灯光。
- 本示例仅为单栋建筑。若渲染多栋建筑,可考虑
通过本文的原理与示例,你已掌握"在 3D 模型墙面上画发光窗户,并用点光增强夜景效果"的核心技巧。希望你在后续项目中,能利用这些方法灵活扩展,打造更真实、更生动的夜晚城市场景。