最近还算清闲,刚好有时间把我们的列车站点管理系统登录页背景 做一些改进,之前的登录页背景是静态的图片,如果能把它改成一个契合系统的背景动画,在为客户演示时应该可以提升一下客户对我们系统的第一印象。
对于列车我们做一个有视差的列车行进动画 比较合适。初步估算我们的背景动画不算复杂,所以选择比较简单直观的svg实现。我将它分成四个部分:天空背景、城市背景、水面、列车,然后为每部分各制作一个移动动画(远慢近快)实现视差效果。
一、天空背景
天空部分我们制作两块白云,移动时改变它们的形状,再做一个渐变的淡蓝色背景,实现详细如下:
- 绘制云朵雏形:用一个椭圆加上原型渐变作为云朵雏形(圆形渐变外圈设置为透明,这样在后面使用滤镜时边缘不会出现截断现象)
- 制作不规则形状:将
feTurbulence
和feDisplacementMap滤镜作用于椭圆,制作不规则形状的云。 - 制作动画:云的形状改变和移动直接修改椭圆x坐标 即可,因为
feTurbulence
滤镜中的噪声值就是基于元素每个点坐标计算而来的(可惜该滤镜不支持3维柏林噪声,不然我们从第3个维度读取噪声值改变云的形状效果会更好) - 绘制一个淡蓝色渐变背景。
html
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="sky-gradient" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#ade6fe"></stop>
<stop offset="100%" stop-color="#ade6fe" stop-opacity="0.3"></stop>
</linearGradient>
<!--云朵颜色-->
<radialGradient id="cloud-gradient">
<stop offset="0%" stop-color="#fff"></stop>
<stop offset="66%" stop-color="#ededed"></stop>
<stop offset="80%" stop-color="#e8e8e8"></stop><!--边缘filter后容易被截断,边缘设置透明-->
<stop offset="100%" stop-color="#e8e8e8" stop-opacity="0"></stop>
</radialGradient>
<!--云朵滤镜-->
<filter id="cloud-filter">
<feTurbulence type="fractalNoise" baseFrequency="0.018" numOctaves="4" seed="0"></feTurbulence>
<feDisplacementMap in="SourceGraphic" scale="60"></feDisplacementMap>
</filter>
<!--天空底色-->
<symbol id="sky-bg-color">
<rect width="100%" height="350" fill="url(#sky-gradient)"></rect>
</symbol>
</defs>
<!--天空背景-->
<g>
<use xlink:href="#sky-bg-color"></use>
<ellipse cx="800" cy="50" rx="180" ry="90" fill="url(#cloud-gradient)" filter="url(#cloud-filter)">
<animate attributeName="cx" values="800;50;800" dur="120s" fill="remove" repeatCount="indefinite" />
</ellipse>
<ellipse cx="460" cy="70" rx="120" ry="40" fill="url(#cloud-gradient)" filter="url(#cloud-filter)">
<animate attributeName="cx" values="460;-200;460" dur="100s" repeatCount="indefinite" />
</ellipse>
</g>
</svg>
二、城市背景
城市我们先收集到一些建筑,保留轮廓即可,然后制作3层(远、中、近)由远及近颜色逐渐加深,看起来有距离变化效果。城市一直向左移动,要看起来要有无限的城市背景效果。实现详细如下:
- 建筑随机放置:根据已有的建筑物素材用js随机生成它们的位置 (这里y轴上我用了一个正弦函数,对振幅和相位稍作改变,因为我希望有一些明显的起伏效果)
- 用上面的方法制作3层,第一层和第三层我们底部使用一个元素遮挡地面与建筑物底部,这样看起来不会太违和。
- 动画部分:我们先用
<use>
元素将上面绘制的城市复制一份 ,移动到其末尾,然后动画上移动一份城市的宽度,这样第二帧动画重置时会与第二份城市视觉上重叠达到无限背景的效果。
html
<defs>
<clipPath id="city-clip">
<rect x="0" y="0" width="1000" height="350"></rect>
</clipPath>
<!--建筑物-->
<symbol id="hours_0">
<polygon points="0,20 10,0 20,0 30,20 30,260 0,260"></polygon>
</symbol>
<symbol id="hours_1">
<polygon points="0,20 40,0 40,260 0,260"></polygon>
</symbol>
<symbol id="hours_2">
<polygon points="0,0 30,10 30,60 35,70 35,260 -5,260 -5,100 0,90"></polygon>
</symbol>
<symbol id="hours_3">
<polygon points="0,10 5,10 5,5 10,5 10,0 20,0 20,5 25,5 25,10 30,10 30,260 0,260"></polygon>
</symbol>
<symbol id="hours_4">
<rect width="5" height="50" x="17.5" y="0"></rect>
<rect width="10" height="50" x="15" y="50"></rect>
<rect width="20" height="160" x="10" y="100"></rect>
<ellipse cx="20" cy="50" rx="10" ry="6"></ellipse>
<ellipse cx="20" cy="100" rx="15" ry="15"></ellipse>
<ellipse cx="20" cy="150" rx="20" ry="15"></ellipse>
</symbol>
<symbol id="hours_5">
<path d="M0 260V20Q20 0,40 20V260H0"></path>
</symbol>
<symbol id="hours_6">
<path d="M0 260V80Q0 0,30 0H35V260H0"></path>
</symbol>
<symbol id="hours_7">
<path d="M20 260C-5 130,30 50,60 130Q70 200,50 260L40 260C50 160,40 130,30 160Q20 200,35 260"></path>
</symbol>
<symbol id="hours_8">
<rect width="40" height="80" x="20" y="200"></rect>
<circle cx="40" cy="180" r="40"></circle>
</symbol>
<symbol id="hours_9">
<path d="M0 260Q20 130,6 40Q20 30,35 40Q30 150,40 260"></path>
<polygon points="15,55 20,10 25,55"></polygon>
</symbol>
<symbol id="hours_10">
<polygon points="0,260 0,10 12,-5 12,5 18,5 18,-5 30,10 30,260"></polygon>
</symbol>
<symbol id="hours_11">
<path d="M0 260V60Q5 50,10 50H20Q25 50,30 60V0H55V260"></path>
</symbol>
<symbol id="hours_12">
<polygon points="10,260 10,20 0,20 10,0 70,0 80,20 70,20 70,260"></polygon>
</symbol>
<!--城市块-->
<symbol id="city_level" clip-path="url(#city-clip)">
<!--城市背景1-->
<g transform="translate(0,100)" fill="#5fc6ff">
<path :d="`M0 260L0 200Q${CITY_WIDTH / 2} 230,${CITY_WIDTH} 200V260H0`" />
<use v-for="(hours, index) of bgHoursList" :key="index" :xlink:href="`#hours_${hours.id}`"
:transform="`translate(${hours.x},${hours.y})`" />
<polygon :transform="`translate(${CITY_WIDTH - 100},0)`" points="0,260 0,220 30,220 30,200 60,200 60,160 100,160 100,260" />
</g>
<!--城市背景2-->
<g transform="translate(0,130)" fill="#29a4ff">
<use v-for="(hours, index) of middleHoursList" :key="index" :xlink:href="`#hours_${hours.id}`"
:transform="`translate(${hours.x},${hours.y})`" />
<use xlink:href="#hours_lun" :transform="`translate(${CITY_WIDTH - 100},140)`" />
</g>
<!--城市背景3 -->
<g transform="translate(0,160)" fill="#037fdd">
<use v-for="(hours, index) of frontHoursList" :key="index" :xlink:href="`#hours_${hours.id}`"
:transform="`translate(${hours.x},${hours.y})`" />
<!--遮挡建筑与地面接触部分-->
<path transform="translate(0,80)" d="M0 110L0 100L50 100C45 90,65 80,75 86Q90 75,95 90L110 100C90 80,100 60,140 80C110 70,160 40,200 70Q230 70,220 100Q350 110,500 100L500 110" />
<path transform="translate(500,80)" d="M0 110L0 100C0 80,30 70,40 90L45 70L50 100C50 70,80 50,120 80C120 60,160 70,160 100Q300 110,400 100C360 70,420 65,410 70C400 60,460 40,450 70C460 70,490 60,510 100L500 110" />
</g>
</symbol>
</defs>
<g>
<!--城市背景-->
<use xlink:href="#city_level" />
<use xlink:href="#city_level" :transform="`translate(${CITY_WIDTH-1},0)`" />
<animateTransform attributeName="transform" type="translateX" values="0;-1000" dur="50s" repeatCount="indefinite" />
</g>
<script setup>
const HOURS_COUNT = 13;
const CITY_WIDTH = 1000;
/**随机建筑生成
* @param {Number} num 建筑数量
* @param {Number} offsetAngle 偏移角度
* @param {Number} yScope y值变化范围
*/
function getRandomHoursArr(num, offsetAngle = 0, yScope = 30) {
// 减去100,防止越过 固定宽
const dist = Math.round((CITY_WIDTH - 100) / num); // 每个hours的宽度
const _arr = [];
let xSum = 0, hoursX = 0;
for (let i = 0; i < num; i++) {
hoursX = xSum + Math.round(Math.random() * dist);
_arr.push({
id: Math.round(Math.random() * (HOURS_COUNT - 1)),
x: hoursX,
y: Math.round(Math.sin(((offsetAngle + hoursX) / 180) * Math.PI) * yScope) + Math.random() * 10 - 5,
});
xSum += dist;
}
return _arr;
}
const bgHoursList = getRandomHoursArr(20, 0);
const middleHoursList = getRandomHoursArr(16, 80);
const frontHoursList = getRandomHoursArr(12, 40);
</script>
三、水面模拟
真实的水面模拟比较复杂,我们只制作一个卡通版的,会舍弃许多效果,能辨识出是水面即可。实现详细如下:
- 制作倒影:使用
<use>
元素复制上面做好的城市,天空 然后将y轴反转(svg元素不能使用css的box-reflect
属性) - 倒影菲涅尔效果:在反射效果中,离观察者近的反射得更为模糊,这不完全是线性的。我们这里只用一个透明度渐变的遮罩来模拟。
- 制作波纹效果:水的波纹也应该带有菲涅尔效果,但在
svg
中我并没有找到好的方法来模拟,因此这里只是同上面云的实现一样使用相同类型的滤镜来扭曲水面。 - 制作水面动画:波纹的变化在
feTurbulence
滤镜下使用animate
动画改变倍频的第二个值即可,移动动画要与城市背景同步,所以同城市使用的同一个动画。
html
<defs>
<!--遮罩用渐变-->
<linearGradient id="mask-gradient" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#fff" stop-opacity="0"></stop>
<stop offset="40%" stop-color="#fff" stop-opacity="0.2"></stop>
<stop offset="100%" stop-color="#fff" stop-opacity="0.9"></stop>
</linearGradient>
<!--波纹滤镜-->
<filter id="water-filter">
<feTurbulence type="turbulence" baseFrequency="0.0021 0.022" numOctaves="3" seed="1">
<animate attributeName="baseFrequency" values="0.0021 0.022;0.0021 0.082;0.0021 0.022" dur="80s" fill="remove"
repeatCount="indefinite"></animate>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" xChannelSelector="B" yChannelSelector="A" scale="10"></feDisplacementMap>
</filter>
<!--菲涅尔效果的遮罩-->
<mask id="reflect-mask">
<rect width="100%" height="360" fill="url(#mask-gradient)"></rect>
</mask>
</defs>
<g>
<!--城市背景-->
<use xlink:href="#city_level" />
<use xlink:href="#city_level" :transform="`translate(${CITY_WIDTH-1},0)`" />
<!--水面,与城市背景共用一个动画,所以放在一起-->
<g transform-origin="500 176" transform="translate(0,295),scale(1,-0.7)" mask="url(#reflect-mask)">
<!--遮挡边缘-->
<rect width="2000" height="30" fill="#037fdd" y="320"></rect>
<use xlink:href="#sky-bg-color" filter="url(#water-filter)"></use>
<use xlink:href="#city_level" filter="url(#water-filter)" />
<use xlink:href="#city_level" filter="url(#water-filter)" :transform="`translate(${CITY_WIDTH-1},0)`" />
</g>
<animateTransform attributeName="transform" type="translateX" values="0;-1000" dur="50s" repeatCount="indefinite" />
</g>
四、列车
列车部分比较简单,我们只用将画好的列车放到合适的位置,移动动画则使用列车下方的桥梁向左移动实现,无限循环的动画制作方式与城市背景动画一样的实现方法。不过移动的速度需要后面的背景移动更快得到视差效果。
html
<defs>
<!--火车部件-->
<symbol id="train-door">
<rect width="16" height="25" rx="3" ry="3" fill="none" stroke="#000" />
<!-- <line x1="8" y1="0" x2="8" y2="25" stroke="#000"/> -->
<circle cx="8" cy="10" r="4" />
</symbol>
<symbol id="train-window">
<rect width="20" height="10" rx="3" ry="3" fill="#2e2e2e" />
</symbol>
<symbol id="train-head">
<path d="M0 0H100Q110 20,105 40L0 40" fill="#daeaf6" />
<path d="M103 10Q108 20,105 25L90 25L90 10" fill="#237b8c" />
<path d="M0 25H106Q107 30,104 40L0 40" fill="#ed414e" />
<use xlink:href="#train-window" transform="translate(35,10)" />
<use xlink:href="#train-window" transform="translate(65,10)" />
<use xlink:href="#train-door" transform="translate(10,10)" />
</symbol>
<symbol id="train-body">
<rect width="120" height="40" rx="3" ry="3" fill="#daeaf6" />
<rect width="120" height="15" y="25" fill="#ed414e" />
<use xlink:href="#train-window" transform="translate(35,10)" />
<use xlink:href="#train-window" transform="translate(65,10)" />
<use xlink:href="#train-door" transform="translate(95,10)" />
<use xlink:href="#train-door" transform="translate(10,10)" />
</symbol>
<!--桥墩-->
<symbol id="bridge-pier">
<polygon points="0,0 0,10 5,10 5,160 0,160 0,170 30,170 30,160 25,160 25,10 30,10 30,0"></polygon>
</symbol>
<!--一段桥 宽:550,高:170-->
<symbol id="bridge">
<rect width="500" height="10"></rect>
<use xlink:href="#bridge-pier" transform="translate(50,8)"></use>
<use xlink:href="#bridge-pier" transform="translate(150,8)"></use>
<use xlink:href="#bridge-pier" transform="translate(250,8)"></use>
<use xlink:href="#bridge-pier" transform="translate(350,8)"></use>
<use xlink:href="#bridge-pier" transform="translate(450,8)"></use>
</symbol>
</defs>
<!--火车-->
<g transform="translate(0,400)">
<use xlink:href="#train-body" transform="translate(0,0)"></use>
<use xlink:href="#train-body" transform="translate(125,0)"></use>
<use xlink:href="#train-head" transform="translate(250,0)"></use>
</g>
<!--桥-->
<g transform="translate(0,435)">
<g fill="#172540">
<use xlink:href="#bridge" transform="translate(-500,0)"></use>
<use xlink:href="#bridge" transform="translate(0,0)"></use>
<use xlink:href="#bridge" transform="translate(500,0)"></use>
<use xlink:href="#bridge" transform="translate(1000,0)"></use>
<animateTransform attributeName="transform" type="translateX" values="0;-500" dur="1.5s"
repeatCount="indefinite" />
</g>
</g>
最后调整一下各部分的位置,使用裁剪将主体部分剪切出来显示即可。