Three.js 教程:夜晚城市窗户发光的实现原理

目录

  1. 引言

  2. 项目整体架构概览

  3. [几何形状与纹理坐标 (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")

  4. 窗户位置的计算原理

    1. [模型空间 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")
    2. [UV 坐标的作用](#UV 坐标的作用 "#uv-%E5%9D%90%E6%A0%87%E7%9A%84%E4%BD%9C%E7%94%A8")
    3. [getWindowPattern 函数详解](#getWindowPattern 函数详解 "#getwindowpattern-%E5%87%BD%E6%95%B0%E8%AF%A6%E8%A7%A3")
    4. [示意图: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")
  5. 自发光(Emissive)原理详解

    1. [什么是 Emissive?](#什么是 Emissive? "#%E4%BB%80%E4%B9%88%E6%98%AF-emissive")
    2. [在 PBR 材质中的作用](#在 PBR 材质中的作用 "#%E5%9C%A8-pbr-%E6%9D%90%E8%B4%A8%E4%B8%AD%E7%9A%84%E4%BD%9C%E7%94%A8")
    3. [如何在 Shader 中叠加 Emissive](#如何在 Shader 中叠加 Emissive "#%E5%A6%82%E4%BD%95%E5%9C%A8-shader-%E4%B8%AD%E5%8F%A0%E5%8A%A0-emissive")
  6. 完整代码拆解与流程说明

    1. 场景与相机设置
    2. 建筑模型与材质创建
    3. [Shader 修改:onBeforeCompile 钩子解析](#Shader 修改:onBeforeCompile 钩子解析 "#shader-%E4%BF%AE%E6%94%B9onbeforecompile-%E9%92%A9%E5%AD%90%E8%A7%A3%E6%9E%90")
    4. 点光源放置:贴近窗户外部
    5. 鼠标交互与渲染循环
  7. 示例效果与调试建议

  8. 总结与延伸思考


引言

在 3D 场景中,如果想模拟夜晚城市中高楼的灯火效果,"窗户发光"是最常见的视觉元素之一。一个直观的方法是:

  1. 自发光(emissive) 在墙体上直接画出发光的窗户格。
  2. 几何外部贴近窗户 放置点光源,制造局部高光与阴影对比,增强真实感。

本文将以一个完整的 Three.js 示例为蓝本,讲解窗户位置的计算Emissive 实现点光源放置 的数学和图形学原理,并配以示意图帮助理解。


项目整体架构概览

在阅读下文之前,先快速了解示例中各部分的关系:

  1. 几何体 (BoxGeometry)

    • 大小为 2×4×2,中心在原点。

    • 由于放在 (0,0,0),在模型空间内的顶点坐标:

      • X ∈ [−1, +1]
      • Y ∈ [−2, +2]
      • Z ∈ [−1, +1]
  2. 纹理坐标 (UV)

    • BoxGeometry 自带每个面的 UV 都是 [0,1] 范围,覆盖整个面。
    • 我们在 Shader 中通过修改 UV 或用 UV 乘系数来做"网格分割"。
  3. 材质 (MeshStandardMaterial + onBeforeCompile)

    • 创建 PBR 材质,原本会根据场景灯光、材质参数(roughness/metalness)进行标准渲染。

    • 通过 onBeforeCompile 钩子,在生成 Shader 时插入自定义代码:

      • 计算哪些像素属于"窗户格",在这些位置叠加自发光颜色。
      • 保留 PBR 的光照计算,确保墙体在点光照射下产生高光和阴影。
  4. 点光源 (PointLight)

    • 原本我们随机在盒子内部放光源,但这样看不到从窗户透出的效果。
    • 改为 放在盒子外侧、紧贴窗户面,让点光直接打亮墙体的"窗户区域"与其周围。

下图是整体逻辑流程示意(图中流程为示意,实际在 Shader 和 Three.js 代码中交互):

scss 复制代码
几何 (模型空间) ─┐
                 ├─> Vertex Shader(计算 vPosition, vWorldNormal 等 varying)  
Shader onBeforeCompile ──┼─> Fragment Shader(判断窗户区域并叠加 Emissive,然后执行 PBR 光照)  
点光源 (世界空间) ────┘  

几何形状与纹理坐标 (UV) 概述

在做窗户效果时,要理解两个核心概念:模型空间下的坐标对应的 UV 纹理坐标

  1. 模型空间 (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; 把"模型空间坐标"传到片元阶段。此坐标用于判断"当前片元是否在侧面"以及"窗户高度"。

  2. 纹理坐标 (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 材质中的作用

  1. 在 Fragment Shader 中,Three.js 会计算 totalEmissiveRadiance

    • 这是一个累加了材质 emissive 颜色的变量,在执行完所有的漫反射 + 镜面反射 + 阴影 + 环境光后,最终将 totalEmissiveRadiance 添加到输出颜色。

    • 伪代码示例如下:

      glsl 复制代码
      vec3 totalEmissiveRadiance = vec3(0.0);
      #include <emissivemap_fragment> // 处理材质的 emissiveMap 或 emissiveUniform
      // ... 其他 PBR 计算(漫反射、镜面反射、阴影、环境光等)
      gl_FragColor = vec4(outgoingLight + totalEmissiveRadiance, alpha);
    • 只要我们在 #include <emissivemap_fragment> 之后增加:

      glsl 复制代码
      totalEmissiveRadiance += windowColor * glowIntensity;

      就能让"窗户像素"产生自发光效果。

  2. 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,
});
  • windowSizewindowSpacing 用于 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;
};

关键解析

  1. 顶点阶段 插入 vUv, vPosition, vWorldPosition, vWorldNormal,方便片元阶段使用。
  2. 片元阶段 先声明 getWindowPattern 函数和相关 uniform/varying,随后用 replace 将逻辑粘贴到 #include <emissivemap_fragment> 之后。
  3. isSide:判断是否在侧面;仅在侧面执行窗户发光,否则跳过。
  4. getWindowPattern(vUv * 4.0) 判断当前 UV 坐标是否落在网格中心的窗户区域。
  5. height 控制窗口发光梯度;flicker 通过三角函数产生闪烁。
  6. 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 = ±1Z = ±1
    • 放在外部则用 X = ±1.1Z = ±1.1,离墙体稍微 0.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 保持呼应,增强夜景生动感。

示例效果与调试建议

  1. 查看侧面灯光分布

    • 运行后,你会看到墙面被划分为 4×4 网格,每个网格中心所属窗户实际发光。
    • 8 盏点光贴近墙面分布,它们会投射暖色光斑,形成局部高光。
  2. 调节窗户参数

    • "窗户发光强度"(windowEmission)越大,自发光越亮。
    • "窗户光晕强度"(windowBloom)可在后处理 Pass 中调节更柔和的泛光。
  3. 调节点光属性

    • 如果某些窗户不够亮,可增大该面上点光的 intensitydistance,让光覆盖更广。
    • SpotLight 代替 PointLight 可以让光束更聚焦,专门打到单个窗格。
  4. 旋转观察

    • 拖拽时可看到点光在不同角度如何照亮侧面墙体,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,让它们透明,但这会影响深度与阴影计算,需谨慎使用。
  • Bloom 后期泛光

    • 若想获得更柔和的夜景光晕,可将 Emissive 通道单独渲染到一个 RenderTarget,再用 UnrealBloomPass 做后处理模糊。
  • 真实玻璃折射

    • 如果追求更高真实感,可在每个窗户面上使用 MeshPhysicalMaterial 并启用 transmission 与半透明特性,让内部光线真实折射与反射。
  • 建筑群场景优化

    • 本示例仅为单栋建筑。若渲染多栋建筑,可考虑 InstancedMesh 或几何合并来提升性能,并将窗户 Emissive 烘焙到贴图,再配合实时点光或环境灯光。

通过本文的原理与示例,你已掌握"在 3D 模型墙面上画发光窗户,并用点光增强夜景效果"的核心技巧。希望你在后续项目中,能利用这些方法灵活扩展,打造更真实、更生动的夜晚城市场景。

相关推荐
后海 0_o16 分钟前
2025前端微服务 - 无界 的实战应用
前端·微服务·架构
Scabbards_18 分钟前
CPT304-2425-S2-Software Engineering II
前端
小满zs23 分钟前
Zustand 第二章(状态处理)
前端·react.js
程序猿小D26 分钟前
第16节 Node.js 文件系统
linux·服务器·前端·node.js·编辑器·vim
萌萌哒草头将军28 分钟前
🚀🚀🚀Prisma 发布无 Rust 引擎预览版,安装和使用更轻量;支持任何 ORM 连接引擎;支持自动备份...
前端·javascript·vue.js
狼性书生42 分钟前
uniapp实现的简约美观的星级评分组件
前端·uni-app·vue·组件
书语时1 小时前
ES6 Promise 状态机
前端·javascript·es6
拉不动的猪1 小时前
管理不同权限用户的左侧菜单展示以及权限按钮的启用 / 禁用之其中一种解决方案
前端·javascript·面试
西陵1 小时前
前端框架渲染DOM的的方式你知道多少?
前端·javascript·架构
小九九的爸爸1 小时前
我是如何让AI帮我还原设计稿的
前端·人工智能·ai编程