从零搭一座可交互 3D 世界(一下)——让天空和城亮起来

0 阅读4分钟

Vue + Three.js 实战:从零搭一座可交互 3D 世界(一下)——让天空和城亮起来

image.png

image.png

上一篇:空世界已经能转,灯永远停在白天。
这一篇只做三件事:铺一张天空、放下城市、用时钟把白天调成黑夜。

拖面板上的时间滑条,城会从正午走到夜里——这就是本篇的验收标准。


1. 为什么不换两套天空?

新手常做:白天一张图,夜里再换一张。

我们只加载 一张 HDR,靠三个旋钮把同一张天空调暗、调冷:

旋钮作用
三盏灯的颜色 / 强度 / 位置太阳变月亮
toneMappingExposure整屏曝光
environmentIntensity / backgroundIntensity同一张 HDR 变「夜空」

好处:不用维护两套贴图,黎明和黄昏只要在 0~1 之间插值,过渡是连续的。


2. 先铺天空:一张 HDR 干两件事

HDR 同时当作:

  • 背景:你看见的天空(scene.background
  • 环境光:楼面、地面会反射周围(scene.environment
_createEnvironment() {
  const url = `${process.env.BASE_URL || "/"}hdr/citrus_orchard_puresky_2k.hdr`;

  new RGBELoader().load(url, (texture) => {
    texture.mapping = THREE.EquirectangularReflectionMapping;
    this.scene.environment = texture;
    this.scene.background = texture;
    // 贴图后到,按当前时钟再刷一遍亮度
    this.setClockHour(this.clockHour);
  });
}

上一篇渲染器里的 ACESFilmicToneMapping 就是为它准备的。不开的话,天空容易过曝,楼面会发灰。

:::warning HDR 是异步的。你可能已经把滑条拖到晚上,贴图加载完仍会把天空拉回默认亮度。
所以回调里要再执行一次 setClockHour。 :::

到这一步:背景不再是纯色块,但城还没进来,夜景也还调不了。


3. 再放下城:缩放、居中、贴地

第三方城市模型单位经常大得离谱。加载后只做三步:

  1. 按水平最长边,缩到目标尺寸
  2. XZ 移到原点中央
  3. 底部贴到 y = 0
_createCity() {
  const url = `${process.env.BASE_URL || "/"}models/ccity_building_set_1.glb`;
  const targetSize = this.options.citySize ?? 160;

  new GLTFLoader().load(url, (gltf) => {
    const root = gltf.scene;

    const box = new THREE.Box3().setFromObject(root);
    const size = box.getSize(new THREE.Vector3());
    const scale = targetSize / Math.max(size.x, size.z);
    root.scale.setScalar(scale);

    // 缩放后再量一次:居中 + 贴地
    box.setFromObject(root);
    const center = box.getCenter(new THREE.Vector3());
    root.position.x -= center.x;
    root.position.z -= center.z;
    root.position.y -= box.min.y;

    this.city = root;
    this.scene.add(root);
    this.setClockHour(this.clockHour); // 窗户亮度要对上当前时间
  });
}

城市也是异步的。加载完同样要再刷一遍时钟,否则你先把时间拨到夜里,楼窗还是白天的亮度。

:::tip 远处楼被切掉?上一篇已经把相机 far 拉大了。城越大,far 越要跟着走。 :::


4. 时钟:先算出「现在有多夜」

不直接写 if (晚上) ...。先把小时变成一个 0~1 的数:

  • 0:纯白天
  • 1:纯黑夜
  • 中间:黎明、黄昏在过渡
时段小时夜晚因子
白天7–170
黄昏17–190 → 1
黑夜19–51
黎明5–71 → 0
function nightFactorFromHour(hour) {
  const h = ((hour % 24) + 24) % 24;
  if (h >= 7 && h < 17) return 0;          // 白天
  if (h >= 19 || h < 5) return 1;          // 黑夜
  if (h >= 17 && h < 19) return (h - 17) / 2; // 黄昏:两小时内变黑
  return 1 - (h - 5) / 2;                  // 黎明:两小时内变亮
}

后面所有灯光、曝光、天空亮度,都吃这一个数。


5. 两套预设,按夜晚因子混合

只准备白天、黑夜两套数字。黄昏不用第三套贴图,对两个颜色做插值就行。

const TIME_OF_DAY_PRESETS = {
  day: {
    hemiSky: 0xffffff,
    dirColor: 0xffffff,
    dirIntensity: 1.1,
    exposure: 1,
    envIntensity: 1,
    bgIntensity: 1,
    cityEnv: 0.85,
    cityEmissive: 1,
  },
  night: {
    hemiSky: 0x1a2848,   // 天光偏蓝
    dirColor: 0xa8c4ff,  // 月光偏冷
    dirIntensity: 0.32,
    exposure: 0.52,
    envIntensity: 0.2,   // 压暗 HDR
    bgIntensity: 0.1,
    cityEnv: 0.28,
    cityEmissive: 2.2,   // 抬高窗户
  },
};

混合(颜色用 lerp,数字按比例):

function blendTimePreset(nightT) {
  const d = TIME_OF_DAY_PRESETS.day;
  const n = TIME_OF_DAY_PRESETS.night;
  const t = THREE.MathUtils.clamp(nightT, 0, 1);
  const mix = (a, b) => a + (b - a) * t;
  return {
    hemiSky: new THREE.Color(d.hemiSky).lerp(new THREE.Color(n.hemiSky), t),
    dirColor: new THREE.Color(d.dirColor).lerp(new THREE.Color(n.dirColor), t),
    dirIntensity: mix(d.dirIntensity, n.dirIntensity),
    exposure: mix(d.exposure, n.exposure),
    envIntensity: mix(d.envIntensity, n.envIntensity),
    bgIntensity: mix(d.bgIntensity, n.bgIntensity),
    cityEnv: mix(d.cityEnv, n.cityEnv),
    cityEmissive: mix(d.cityEmissive, n.cityEmissive),
  };
}

t = 0 全是白天,t = 1 全是黑夜,t = 0.5 就是黄昏。


6. 太阳跟着钟走

光只变暗、方向不变,阴影会假。约定:大约 6 点升起、正午在头顶、18 点落下;夜里改成对侧「月光」。

function sunDirFromHour(hour, out) {
  const h = ((hour % 24) + 24) % 24;
  const elev = ((h - 6) / 12) * Math.PI; // 6:00=地平线,12:00=头顶
  const y = Math.sin(elev);
  const xz = Math.cos(elev);
  if (y >= 0.05) {
    out.set(xz * 18, Math.max(y * 22, 2), 10); // 太阳
  } else {
    out.set(-xz * 14, 16, -8); // 月光,从对面来
  }
  return out;
}

主方向灯的 position 用这个向量,阴影就会跟着时间转。


7. 写回场景:一次改灯、曝光、天空

对外只暴露一个入口 setClockHour。里面三步:算夜晚因子 → 混合预设 → 写回零件。

setClockHour(hour) {
  this.clockHour = ((Number(hour) % 24) + 24) % 24;
  const nightT = nightFactorFromHour(this.clockHour);
  const preset = blendTimePreset(nightT);

  const { hemi, dir } = this.lights;
  hemi.color.copy(preset.hemiSky);
  dir.color.copy(preset.dirColor);
  dir.intensity = preset.dirIntensity;
  sunDirFromHour(this.clockHour, this._sunDir);
  dir.position.copy(this._sunDir);

  this.renderer.toneMappingExposure = preset.exposure;
  this.scene.environmentIntensity = preset.envIntensity;
  this.scene.backgroundIntensity = preset.bgIntensity;

  this._applyCityTimeMaterials(preset.cityEnv, preset.cityEmissive);
}

页面滑条只调这一行:

function applyClockHour() {
  world.setClockHour(clockHour.value);
  periodLabel.value = world.getPeriodLabel(); // 黎明 / 白天 / 黄昏 / 黑夜
}

天气以后会再乘一层压暗、起雾,但时间源只有 clockHour。本篇先当永远晴天。


8. 夜里只点亮「真的会发光」的窗户

不是整座城一起变亮。只改带自发光贴图(或自发光颜色)的材质:白天约 1,夜里约 2.2。普通墙只压环境反射,不会乱亮。

_applyCityTimeMaterials(envIntensity, emissiveIntensity) {
  if (!this.city) return;
  this.city.traverse((obj) => {
    if (!obj.isMesh) return;
    const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
    mats.forEach((m) => {
      if (!m || !m.isMeshStandardMaterial) return;
      m.envMapIntensity = envIntensity;
      if (m.emissiveMap || (m.emissive && m.emissive.getHex() > 0)) {
        m.emissiveIntensity = emissiveIntensity;
      }
    });
  });
}

城市晚于时钟加载时,第 3 节加载回调里那次 setClockHour,就是为了把窗户亮度补上。


9. 本篇验收

  1. 天空不再是纯色,楼面会有一点环境反射
  2. 城贴在地上、大致在画面中央
  3. 拖时间滑条:黎明 → 白天 → 黄昏 → 黑夜,天空、阴影方向、楼窗跟着变

夜景发灰:ACES 和 HDR 有没有生效。
城还在白天亮度:HDR 或城市加载完有没有再调一次时钟。


本篇小结

上一篇的空世界
  → 一张 HDR(天空 + 环境光)
  → 城市缩放、居中、贴地
  → 小时 → 夜晚因子 0~1
  → 白天/黑夜预设混合
  → 太阳方向 + 曝光 + 窗户自发光

城在、光对、能转。下一篇往里面装身体:碰撞、胶囊、角色表。


下篇预告:《身体进场》

  • 城市碰撞怎么从楼「烤」出来
  • 角色胶囊为什么能站住
  • 一张配置表驱动全员
  • 键盘只写速度,位置交给物理