Vue + Three.js 实战:从零搭一座可交互 3D 世界(一下)——让天空和城亮起来
上一篇:空世界已经能转,灯永远停在白天。
这一篇只做三件事:铺一张天空、放下城市、用时钟把白天调成黑夜。
拖面板上的时间滑条,城会从正午走到夜里——这就是本篇的验收标准。
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. 再放下城:缩放、居中、贴地
第三方城市模型单位经常大得离谱。加载后只做三步:
- 按水平最长边,缩到目标尺寸
- XZ 移到原点中央
- 底部贴到
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–17 | 0 |
| 黄昏 | 17–19 | 0 → 1 |
| 黑夜 | 19–5 | 1 |
| 黎明 | 5–7 | 1 → 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. 本篇验收
- 天空不再是纯色,楼面会有一点环境反射
- 城贴在地上、大致在画面中央
- 拖时间滑条:黎明 → 白天 → 黄昏 → 黑夜,天空、阴影方向、楼窗跟着变
夜景发灰:ACES 和 HDR 有没有生效。
城还在白天亮度:HDR 或城市加载完有没有再调一次时钟。
本篇小结
上一篇的空世界
→ 一张 HDR(天空 + 环境光)
→ 城市缩放、居中、贴地
→ 小时 → 夜晚因子 0~1
→ 白天/黑夜预设混合
→ 太阳方向 + 曝光 + 窗户自发光
城在、光对、能转。下一篇往里面装身体:碰撞、胶囊、角色表。
下篇预告:《身体进场》
- 城市碰撞怎么从楼「烤」出来
- 角色胶囊为什么能站住
- 一张配置表驱动全员
- 键盘只写速度,位置交给物理