Skip to content

天穹之华

背景:环境系统功能完成率 100%,但体验完成率远低于此——穹顶是"静止的博物馆"。 过程:太阳光晕 + 时间流动画、预设 4→7、水面 Perlin 凹凸纹理 + 水下后处理、程序化星空、天空旋转、水面预设。


体验打磨完了。联邦的手感好了,该把目光重新投向头顶的天穹和脚下的物理。

联邦的穹顶是死的。

DanceXR 的穹顶会动。

议会成员站在 DanceXR 的穹顶下看了十分钟。太阳在缓缓下沉,云在缓缓飘移,水面在风中起皱。回来之后,会议室里沉默了很长时间。

然后有人说:「我们的穹顶是死的。」

这句话比任何代码 review 都难以接受。联邦花了整整一卷才建成穹顶系统——天空、地面、粒子、风、云、雾,一行一行代码,一个一个材质。现在有人告诉你:你建了一座静止的博物馆。

Riku 站在白板前。白板上画着穹顶系统的功能清单,每一项后面都是绿色的勾。

「100%。」他说,「功能完成率 100%。」

他用笔尖戳了戳那个数字。

「但体验完成率——」

没有人接话。那个数字大家心里都有数。

「我们从太阳开始。」Riku 转身走向黑板,「让死的东西活起来,先从最重要的那个开始。」


一、太阳是平的

联邦的天空渐变纹理是用 Canvas 2D 画的。_buildGradientTexture 函数,256×256 像素,三个颜色——顶色、中色、底色——径向渐变,干净利落。

但太阳是平的。

「看。」Riku 指着屏幕,「这是一个圆盘。有颜色,有大小。但没有光。」

光晕是真实世界里的东西。你看向太阳,它周围有一圈朦胧的光,因为大气散射。在渲染里,这叫 bloom,或者 halo。DanceXR 有。联邦没有。

「加一个径向渐变,在太阳位置叠加。」

代码很快写出来。Riku把太阳位置放在画布偏下的地方,光晕半径30像素,从中心的金色往外渐变成透明——这样,太阳就不再是"一个有颜色的圆",而是"一个有光的东西":

typescript
const sunAngle = envState.skyRotationY;
const sunX = 128 + Math.cos(sunAngle) * 64;
const sunY = 64 + Math.sin(sunAngle) * 32;
const haloRadius = 30;
const gradient = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, haloRadius);
gradient.addColorStop(0, `rgba(255, 255, 200, 0.8)`);
gradient.addColorStop(0.5, `rgba(255, 200, 100, 0.3)`);
gradient.addColorStop(1, `rgba(255, 150, 50, 0)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 256, 256);

太阳光晕出现了。它不大,但它在那里。太阳不再是一个平面圆盘——它有光了。

「好。」Riku 说,「现在让它动。」


二、每 100 毫秒一座山

「时间流」——让太阳自动在天空中移动,模拟一天 24 小时。

实现方案很简单:

  1. 添加 startTimeOfDay() 函数,用 setInterval 每 100 毫秒更新 sunAngle
  2. sunAngle 改变 → 太阳位置改变 → 光晕位置改变 → 天空纹理需要重建

「等等。」运行委员会的人打断了 Riku,「每 100 毫秒重建天空纹理?」

Riku 停下了。

「256×256 的 Canvas,每 100 毫秒重建一次。一秒钟十次。一分钟六百次。」

沉默。

「我们用缓存。」运行委员会的人说,「_gradientCache 已经存在了。」

缓存的设计经过了仔细的计算。sunAngle 必须加入缓存键:

缓存键 = `${topR},${topG},${topB},${midR},${midG},${midB},${botR},${botG},${botB},${brightness},${sunAngle},${starsEnabled}`

sunAngle 改变时,先查缓存。命中了,0 毫秒返回。没命中,重建纹理——大约 200 毫秒——存入缓存,下次使用。

「这才是对的。」Riku 在白板上画了一个箭头:

sunAngle 改变 → 查缓存 → 命中 → 直接返回(0ms)
                        → 未命中 → 重建纹理(200ms)→ 存入缓存

时间流动画跑起来了。

Riku 站在屏幕前,看着太阳缓慢地划过天空。光晕跟着它走,颜色随着角度变化——清晨的金色、正午的白色、黄昏的橙色。太阳第一次在联邦的穹顶「活」了。

「下一个。」他说。


三、预设的墙

天空预设原来有四个:清晨、正午、黄昏、夜晚。applyEnvPreset 函数里硬编码了四个颜色数组。

「加三个。」Riku 说,「黎明、薄暮、午夜。」

这看起来是一个简单的任务。复制粘贴,改几个 RGB 值,保存。

但 Riku 没有马上动手。

「如果我们想加第八个预设呢?第九个?」

没有人回答。

「预设应该是数据,不是代码。」

他开始重构。

typescript
interface EnvPreset {
    name: string;
    skyColorTop: [number, number, number];
    skyColorMid: [number, number, number];
    skyColorBot: [number, number, number];
    // ... 其他参数
}

const ENV_PRESETS: EnvPreset[] = [
    { name: "清晨", skyColorTop: [0.3, 0.5, 0.8], ... },
    { name: "正午", skyColorTop: [0.5, 0.7, 1.0], ... },
    // ...
];

applyEnvPreset 变成了:

typescript
export function applyEnvPreset(presetName: string): void {
    const preset = ENV_PRESETS.find(p => p.name === presetName);
    if (preset) {
        setEnvState({ ...preset });
    }
}

四个预设变成了七个。七个变成了可以无限扩展的数据。

「预设是墙。」Riku 在笔记本上写下这句话,「硬编码是砖墙,数据结构是活墙。」


四、水面看见了什么

水面的问题是从一个 bug 开始的。

当 Riku 把相机移到水面以下时,他发现了一个奇怪的现象:水面倒影里映出的不是天空——而是一片黑暗。

WaterMaterial 的反射是假的。」他说,「它反射的不是世界,是一张贴图。」

那张贴图是天空。如果天空是深蓝色的,倒影就是深蓝色。如果天空是黄昏的橙色,倒影就是橙色。这在正常情况下是对的——但当相机入水后,天空应该看不见了,倒影应该反射水下的东西。

「我们想要的是:相机入水后,倒影也跟着变。」

这不是一个简单的 bug fix。这是一个认知升级:水面反射的不是一个「背景图」,而是「世界」本身。

解决方案是 bumpTexture——用 Perlin 噪声生成的凹凸贴图,让水面不再是一面镜子,而是一个有纹理的存在。

typescript
function _generateWaterBumpTexture(size: number = 512): DynamicTexture {
    const texture = new DynamicTexture("waterBump", size, scene, true);
    const ctx = texture.getContext();
    
    for (let y = 0; y < size; y++) {
        for (let x = 0; x < size; x++) {
            const noise = perlin2(x * 0.02, y * 0.02);
            const pixel = Math.floor((noise + 1) * 128);
            ctx.fillStyle = `rgb(${pixel}, ${pixel}, ${pixel})`;
            ctx.fillRect(x, y, 1, 1);
        }
    }
    
    texture.update();
    return texture;
}

水面不再平坦了。它有细微的凹凸,光在水面上反射时会产生微妙的变化。

然后是水下视角。当相机入水后(camera.position.y < waterLevel),联邦自动切换后处理管线——蓝色色调、雾效增强、色差效果,模拟水下看到的世界。

「水看见了世界。」Riku 写下这句话,「不是看见了天空,是看见了世界。」


五、星空,一笔带过

深夜的天空是黑色的。但联邦的深夜天空是深蓝色的。

没有星星。

程序化星空的实现很简单:用 Canvas 2D 在天空纹理上画 200 个白点。部分点加上十字光芒。然后把 Canvas 存成纹理,应用到天空盒。

算法不复杂。但效果是惊人的。

starsEnabled 开启、太阳角度在深夜范围时,星空自动出现在天空纹理上。200 个点,有的亮,有的暗,有的带十字光芒。深夜的天空第一次有了星星。

天空旋转、波浪-风向联动,这些是第三期的小彩蛋。天空盒缓慢旋转,WATER_material 的 windDirection 跟着风系统变化。都是几行代码的事。

真正的彩蛋是那个数字:环境系统的体验完成率,从 60% 升到了 95%。

Riku 站在新穹顶下,看了很久。

「它活了。」


散场

联邦的穹顶不再是「设置一次就用一辈子」的静态配置。

太阳有光晕。光晕跟着太阳走。时间流动画让太阳在天空移动——那不是动画,是时间本身在流逝。星空在深夜出现,是世界在呼吸。水面跟着风起浪,是空气在和水说话。

这不是功能的堆砌。

这是联邦的穹顶学会了「变化」。

静止是死亡的开始。变化是生命的证据。


教训:静态的穹顶只是一张壁纸,能随时间变化的穹顶才是一个世界。