如何绘制一片完美的动态星空

文章描述了作者对星空的执念,并通过WebGL技术实现了星空背景的3.0版本。作者从最初的星空壁纸到使用canvas 2D绘制星空,最终选择了WebGL和babylon.js框架来实现高性能的3D星空效果。文章详细介绍了技术选型、迭代过程、着色器编写、星云效果实现以及星星纹理的生成和优化。最终效果通过粒子系统、鼠标移动事件监听、星云环绕和闪耀星点等特性,营造出穿梭星海的视觉效果。

文章目录

首先放上最终的完成效果:WebGL Starfield

对于星空的执念

最终效果

不知从什么时候开始,我便喜欢上了浩瀚的星空,无论是手机还是电脑壁纸、更换了好多次,大多都是星空或者星空+地景为主题的,以至于我开始学习前端三件套制作我的个人主页时,便使用了一张星空图作为页面背景,这大抵可以称为星空背景的1.0版本。

再后来了解了canvas之后,我便想过用程序绘制星空,以此代替体积巨大的背景图片。由于canvas 2d上下文简单完善的API封装,使得学习和使用起来十分便捷,但这也带来了一些问题:

  • 能实现的效果比较有限,想实现三维效果需要一些黑科技
  • (相对)性能不高,绘制大量复杂图形时尤为明显

当时我也尝试过使用canvas 2d来绘制星空,并且利用视差营造一种伪3D的效果,可以说差强人意,算是星空背景的2.0版本,具体效果在此:中秋快乐

第二版

从这个demo也可以看出来我不敢绘制过多的星星,但即便如此,在我的博客页面内作为背景时还是会时不时引起卡顿,所以最后也没应用到博客中。

此后我也不断寻找着高性能,可自定义程度高的方法在web中绘制三维星空,WebGL作为新的3D绘图协议,使用基于OpenGL ES的图形API,可以高性能地绘制3D图形,但苦于没有相关基础,手写着色器对我来说着实是个难题。即使现在有不少封装完善的引擎如three.js、babylon.js等,大大降低了我们的上手难度,但我认为想实现心中天马行空的设想,底层逻辑的学习必不可少。

插一句题外话,作为Minecraft的老玩家,着色器其实并不是第一次接触,前几年我还在创作资源包的时候,便了解过各类光影包本质上就是利用自定义着色器对游戏画面进行渲染(更巧的是同样基于OpenGL),究其根源shaderpack应该翻译为着色器包,不过对于大众玩家来说,光影包这个翻译确实是更接地气一点。在我慢慢淡出圈子那段时间,官方也是在新版本中逐渐放开了原版自定义着色器的支持,使得圈子内越来越多人开始认识到着色器的强大之处,并在游戏中应用。

技术选型

敲定了WebGL这个技术选型之后,便是选用框架,合适的框架能帮我们减少大量重复工作,从而把主要精力放在制作上,现有影响力较大的Web 3D引擎主要是three.js和babylon.js,前者在我大二的时候用过一段时间(数字孪生项目),由于发布时间较早,国内的相关教程比较多;而后者则是由微软发布的Web渲染引擎,官方文档齐全,代码工程化程度较高。最后我选择了babylon.js。

这次星空背景3.0版本,涉及了三维图形学基础、GLSL、建模、纹理素材绘制等多个额外领域。幸运的是,除了GLSL以及小部分图形学基础之外,其他知识都在我的业余技能库中!

早期Minecraft建模作品:

法师帽

为了方便开发调试,这次使用vite作为构建工具。

迭代之路

从设想到最终效果,中间经历了多次迭代,添加了不少特性,迭代过程总结起来可以分为以下几步:

  1. 添加星星,营造出穿梭在星海中的效果
  2. 添加鼠标移动事件监听器来改变相机位置以增强视差效果
  3. 添加简单的星云效果,丰富画面层次
  4. 用代码重新生成星星图片,用着色器丰富星星显示细节

以下是详细的思路,篇幅原因就不从头介绍相关基础知识了,默认读者了解坐标系、相机等相关知识。

穿梭星海

想要营造出星海遨游的效果,最简单的方式就是在三维空间内随机生成大量星星,然后相机不断往前移动。这固然是一种方法,但是如果改变一下参考系,相机不动,而星星向我们飞过来,在代码上会更好处理。

对于星星的设想比较简单,就是一张精灵图(sprite)从视线方向远处飞过来,精灵图的特性是始终面向相机方向,无论从哪个视角看精灵图都不会产生畸变。在babylon中,我们可以通过粒子系统(Particle System)来完成这项工作。(省略创建场景及相机的代码)

// 创建粒子系统
const particleSystem = new ParticleSystem("particles", 1500, scene);
// 设置粒子贴图
particleSystem.particleTexture = new Texture(
"/assets/textures/star.jpg",
scene,
);
// 设置粒子尺寸
particleSystem.minSize = 0.2;
particleSystem.maxSize = 0.5;
// 设置粒子颜色范围
particleSystem.color1 = new Color4(134 / 255, 13 / 255, 255 / 255, 1);
particleSystem.color2 = new Color4(8 / 255, 255 / 255, 214 / 255, 1);
// 设置粒子生命周期
particleSystem.minLifeTime = 20;
particleSystem.maxLifeTime = 20;
// 设置发射速率,即每秒生成多少粒子
particleSystem.emitRate = 75;
// 创建发射器,前两个参数为粒子的速度向量范围,后两个参数为粒子的生成坐标范围
const boxEmitter = particleSystem.createBoxEmitter(
new Vector3(0, 2.5, 0),
new Vector3(0, 2.5, 0),
new Vector3(-50, 0, -30),
new Vector3(50, 0, 30),
);
// 预热,使得页面加载完成后便有大量粒子(绯红之王效果)
particleSystem.preWarmCycles = 50;
particleSystem.preWarmStepOffset = 30;
particleSystem.start();

用PS随便糊了张中间亮,四周暗的星星图,开启vite dev模式,我们就得到穿梭星海的效果:

第一步效果

视差增强

接下来,就是发挥三维场景的优势,通过监听鼠标移动来相应地移动相机,改变视角,营造更强的视差效果,原理就是通过添加mousemove事件监听器,获取鼠标位置,计算在页面中的位置百分比,从而计算相机应该移动到哪个位置。

以上的思路是鼠标指哪,相机跑哪,但是这样的移动效果显得比较突兀,我们可以通过添加一个缓动函数来让相机移动变得更优雅:

/**
* 缓动函数,需要注意缓动比率要根据画面帧率来计算
* 否则在不同刷新率的显示设备上表现不一致
*
* @param from 旧位置
* @param to 新位置
* @param rate 缓动比率
* @returns 缓动处理后的新位置
*/
transtitionAnimation(from: number, to: number, rate = 0.025) {
return from + (to - from) * Math.min(rate, 1) // 防止帧率过小导致计算的缓动比率大于1的情况
}

其中,缓动比率的计算可以参考deltaTime * 倍率这个公式,deltaTime为上一帧到当前帧经过的时长,可以用engine.getDeltaTime()获取,倍率越小,缓动效果越明显。

星云环绕

在第一小节中,我们完成了星星的制作,但是这片星空看起来还是十分单调,网络上的星空图中,往往还伴随着多彩的星云,营造出梦幻的效果,如何制作颜色丰富的星云确实是个难题,在参考了网上一些资料后,我决定使用一种取巧的办法!

通过建模软件绘制一个没有底面的圆锥模型,将云彩纹理(PS云彩滤镜绘制)映射到圆锥内面上,利用着色器制作云彩飘动的效果(实际上更像是烟雾),在我们的项目中相机和模型的相对位置如图所示(至于云彩纹理为什么是张灰度图,这里先卖个关子):

星云解决方案

将模型导入我们的场景,我们利用Shader Material来制作模型的材质,Shader Material允许我们使用着色器来定制显示效果,添加云彩纹理后我们正式进入着色器编写环节,对于着色器还不了解的读者可以搜索相关文章,对于着色器入门也可以看看这个视频:【译】Shader艺术编码入门介绍(An introduction to Shader Art Coding),视频中提到的不少工具网站也是十分实用的。导入模型和创建材质的代码如下:

// 添加新的顶点着色器和片元着色器,着色器代码略
Effect.ShadersStore["cloudsVertexShader"] = /* glsl */ "这里传入顶点着色器代码";
Effect.ShadersStore["cloudsFragmentShader"] =
/* glsl */ "这里传入片元着色器代码";
// 加载云彩纹理
const cloudTexture = new Texture("/assets/textures/clouds.jpg", scene);
// 创建着色器材质
const shaderMat = new ShaderMaterial(
"cloudsShader",
scene,
{
// 顶点着色器和片元着色器名称,和我们上面添加的着色器名称相对应
vertex: "clouds",
fragment: "clouds",
},
{
attributes: ["position", "normal", "uv"],
uniforms: [
"world",
"worldView",
"worldViewProjection",
"view",
"projection",
"textureSampler",
],
needAlphaBlending: true,
},
);
// 将纹理添加到着色器中
shaderMat.setTexture("textureSampler", cloudTexture);
// 导入模型
SceneLoader.ImportMeshAsync(["cone"], "/assets/models/", "cone.glb", scene)
.then((result) => {
// 从根网格中获取圆锥体网格模型
const cone = result.meshes[0].getChildMeshes()[0];
// 设置材质
cone.material = cloudsMat;
})
.catch((err) => {
console.log(err);
});

接下来就进入真正的着色器编写了,前面提到云彩纹理使用灰度图的原因也可以揭晓了,我们需要通过纹理获取到的仅仅是云彩图的亮度(也就是灰度图单通道的值),然后将亮度映射为最终颜色的alpha通道即不透明度,而rgb通道交给着色器来处理,这样我们可以仅修改着色器代码来实现颜色的改变,而云彩图的文件体积也将大大缩小。

首先我们需要在顶点着色器内将uv传给片元着色器:

// vertex
attribute vec2 uv;
attribute vec3 position;
attribute vec3 normal;
varying vec2 vUv; // varying关键字声明的变量会传递到片元着色器中
uniform mat4 worldViewProjection;
void main(void) {
vUv = uv;
gl_Position = worldViewProjection * vec4(position, 1.0);
}

然后在片元着色器内通过uv获取纹理上的颜色,取出red通道的值作为alpha值(灰度图中三原色通道的值是一致的),你会注意到我这里有一个palette函数,这个函数是用于生成渐变颜色的,在我上面提到的视频中有相关介绍,你可以在这个网站中创建自己的调色板,将对应参数添加到palette函数中,通过传入一个参数t(0.0~1.0)调用这个函数,即可获得渐变色带上对应位置的颜色。

// fragment
varying vec2 vUv;
uniform sampler2D textureSampler;
vec3 palette(float t) {
// [[0.300 0.460 0.830] [0.240 0.158 0.298] [1.000 1.000 1.000] [0.710 1.323 0.788]]
vec3 a = vec3(0.300, 0.460, 0.830);
vec3 b = vec3(0.240, 0.158, 0.298);
vec3 c = vec3(1.000, 1.000, 1.000);
vec3 d = vec3(0.710, 1.323, 0.788);
return a + b * cos(6.28318 * (c * t + d));
}
void main(void) {
vec4 textureColor = texture2D(textureSampler, vUv);
float alpha = textureColor.r;
vec3 color = palette(vUv.x + vUv.y);
gl_FragColor = vec4(color, alpha);
}

这里我将uv的两个分量相加作为参数t,你可以尝试不同的组合,比如仅传入uv的其中一个分量,看看能得到什么效果。最后我们将颜色值和alpha值组合起来作为该点的颜色传递到gl_FragColor中,片元着色器的任务就完成了,让我们看看效果:

第三步效果1

看起来还行,但是缺少一丝生动,并且画面中间的纹理因为处于圆锥顶部而导致拉伸,看起来比较奇怪。接下来,我们来优化这两个问题。

对于静止不动的星云,我打算通过着色器让云彩动起来,运动方向和星星一致,这样更能体现穿梭星海的效果。为此,我们需要在着色器内引入时间变量:

// 创建着色器材质
const shaderMat = new ShaderMaterial(
...// 其他部分不变
{
attributes: ["position", "normal", "uv"],
uniforms: [
"world",
"worldView",
"worldViewProjection",
"view",
"projection",
"textureSampler",
"time",
], // 在这里添加time,这样才能在着色器内引入time变量
needAlphaBlending: true,
},
);
let time = 0.0;
// onBind函数每帧都会运行一次
shaderMat.onBind = function () {
time += scene.getEngine().getDeltaTime() * 0.00002; // 不想让星云飞太快
shaderMat.setFloat("time", time); // 设置time变量
};

回到片元着色器,继续我们的操作。

// fragment
varying vec2 vUv;
uniform sampler2D textureSampler;
uniform float time; // 引入time变量
// 省略palette函数代码
void main(void) {
// 传入uv时让y值加上时间变量(要根据模型uv方向来确定)
vec4 textureColor = texture2D(textureSampler, vec2(vUv.x, vUv.y + time));
float alpha = textureColor.r;
vec3 color = palette(vUv.x + vUv.y + time); // 让颜色也随着时间变化
gl_FragColor = vec4(color, alpha);
}

至此,我们成功让星云动了起来!

至于画面中间显得突兀的问题,也很好解决,不好看就让它隐藏起来!这里就不贴出方法了,有兴趣的读者可以自行尝试解决这个问题。最后我们就得到了这一小节的最终效果:

第三节最终效果

闪耀星点

至此,可以说已经达到一个不错的效果了,无非是调整一下星星颜色让其看起来更亮,但是我个人比较追求细节(笑),现在的效果对我来说还有不足的地方,主要体现在星星效果上,现在的星星亮度偏低,推测问题出在纹理上,用ps制作的话很难达到画面明亮统一且平衡,干脆用代码生成星星纹理吧!

这次我使用python来生成星星图片,用到了Pillow图像处理库,总之就是利用一些简单的数学公式,发挥你的艺术细胞“创作”一张星星图片。

import math
from PIL import Image
def get_color(x, y, w=256, h=256):
# 从边缘到中心渐变,亮度呈指数型变化
max_dis_side = w / 2
dis_center = math.sqrt((x - w / 2) ** 2 + (y - h / 2) ** 2)
rate = max(max_dis_side - dis_center, 0) / max_dis_side * 2
ans = int(round(255 * (math.exp(rate - 1.9)), 0)) - 40
# X对角线上的点也要变亮
if (abs(x - (256 - y)) % 256 < 100 or abs(x - y) % 256 < 100):
tmp = max(100 - (abs(x - (256 - y)) % 256) * 5, 100 - (abs(x - y) % 256) * 5)
ans = max(tmp + ans - math.floor(dis_center / 3), ans)
return ans
# 生成带X光带的星星图片
def gen_shining_star():
image = Image.new('L', (256, 256)) // 生成一张灰度图
for x in range(256):
for y in range(256):
color = get_color(x, y)
image.putpixel((x, y), color)
image.save('./star.jpg', quality=85)
if __name__ == '__main__':
gen_shining_star()

效果如下:

星星纹理

最后,再通过着色器优化一下星星的显示效果,让其中心更亮,具体文档参考Customizing Particles

// fragment
varying vec2 vUV;
varying vec4 vColor;
uniform sampler2D diffuseSampler;
void main(void) {
vec4 textureColor = texture2D(diffuseSampler, vUV);
// 将粒子颜色与亮黄色进行混合,比例基于星星纹理的亮度
vec3 color = mix(vColor.rgb, vec3(1.0, 0.8, 0.3), textureColor.r);
gl_FragColor = vec4(color, 1.0);
}

后记

至此,整个星空已经和Demo页面中的效果十分接近了。当然,我还花了大量时间优化体验,主要体现在各种淡入淡出效果上。毕竟babylon的打包体积着实不小,就算tree-shaking后的js大小也能轻松超过1MB,在vue中使用应该作为异步组件加载,那么淡入效果就能带来更好的用户体验。当然另一种方法则是全部换成原生写法,之后再做权衡。

后续暑假有时间可能会把博客系统翻新一下,后端也许会用Go重构一遍,前端继续保持现在的技术栈,将星空背景添加进去,不过到时候又要考虑明亮模式下的背景,头疼Orz。