一、分析
最近有个需求是要给某楼盘做一个太阳光照模拟,从8点到17点的日照变化。要更好的模拟太阳在一天中的变化,我们只需要知道当前观测点的太阳高度角和太阳方位角,为了更加真是还要多阳光颜色进行差值计算;
我们都知道,地球绕太阳转是地球运动学的基本现象之一,被称为公转。这一运动是地球围绕太阳的椭圆轨道上的周期性变化,它决定了一年的长度。
当谈及地球绕太阳公转时,我们需要考虑到太阳直射点的变化规律。太阳直射点是地球上太阳光线垂直照射的地方,这个点会随着地球绕太阳运动而发生周期性的变化。
在一年的时间里,太阳直射点会在黄道平面上来回移动,形成了两次春分和两次秋分。具体来说:
-
春分时刻: 当太阳直射点位于赤道上方时,地球的北半球迎来春天,南半球迎来秋天。这一时刻标志着昼夜长度相等,春天正式开始。
-
秋分时刻: 当太阳直射点再次回到赤道上方时,地球的北半球迎来秋天,南半球迎来春天。同样,昼夜长度再次相等,秋天正式开始。
太阳直射点的这种变化规律导致了地球各地不同季节的交替。例如,当太阳直射点位于北半球时,北半球将经历夏季,而南半球则经历冬季,反之亦然
知道了,一年中太阳直射点的运动规律。那就好办了,我们只需要知道要观测的目标点的维度就可以了。
这次我们选择的观测点是长沙,为什么呢,因为刚好项目就是长沙的,哈哈哈
长沙经纬度=28 °11′49〃N,112 °58′42〃
然后我们去网上查询了(其实是用chatGTP查询的啦^^)近五年长沙年历太阳高度角和方位角的变化,然后取了平均值
国际天文学联合会,可以查到,经过整理我们得到了以下数据
// 长沙春分,夏至,秋分,冬至从早上8点到下午5点太阳方位角和太阳高度角变化,数据来源国际天文学联合会 www.timeanddate.com
const CHANGSHA = [
[
[22.86, 30.45, 38.11, 44.94, 49.03, 44.68, 37.68, 30.11, 22.62, 15.71],
[
119.62, 126.43, 143.12, 165.6, 180, 195.63, 218.37, 235.08, 246.64,
253.77,
],
],
[
[61.19, 68.25, 74.15, 77.59, 77.86, 74.77, 68.19, 60.09, 51.47, 43.13],
[
84.51, 98.94, 125.33, 156.77, 178.06, 198.51, 225.16, 239.45, 250.1,
256.54,
],
],
[
[22.89, 30.16, 37.51, 44.26, 48.31, 43.99, 36.85, 29.13, 21.49, 14.38],
[
120.38, 127.25, 144.28, 167.62, 180.0, 192.64, 214.02, 230.44, 241.84,
249.24,
],
],
[
[22.04, 29.08, 35.36, 40.43, 43.03, 40.76, 34.97, 28.38, 21.43, 15.06],
[120.5, 127.8, 144.57, 167.15, 180, 193.71, 216.14, 232.91, 244.67, 252.4],
],
];
export { CHANGSHA };
有了这些数据了,那如何根据太阳高度角和太阳方位角,在天空中生成我们的太阳呢?难不成自己用着色器来写?万万不可啊。这么玩的话也不是不行,得加钱啊。
我们可以用three.js给我们提供的Sky类来生成天空,暴露出了几个uniforms的值用来调整太阳的参数,其中最重要的就是我们的phi, theta,然后将球坐标转成三维坐标赋值给sunPosition
import * as THREE from "three";
import { Sky } from "three/examples/jsm/objects/Sky.js";
export default class Sun {
sky: Sky;
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer
) {
const sun = new THREE.Vector3();
this.sky = new Sky();
this.sky.scale.setScalar(40000);
scene.add(this.sky);
// 初始值
const effectController = {
turbidity: 20,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: 2,
azimuth: 180,
};
const uniforms = this.sky.material.uniforms;
uniforms["turbidity"].value = effectController.turbidity;
uniforms["rayleigh"].value = effectController.rayleigh;
uniforms["mieCoefficient"].value = effectController.mieCoefficient;
uniforms["mieDirectionalG"].value = effectController.mieDirectionalG;
const phi = THREE.MathUtils.degToRad(90 - effectController.elevation);
const theta = THREE.MathUtils.degToRad(effectController.azimuth);
sun.setFromSphericalCoords(1, phi, theta);
uniforms["sunPosition"].value.copy(sun);
renderer.toneMappingExposure = 1;
}
}
天空的太阳有了,那我们可以根据太阳的位置和我们的位置,我们的位置默认在原点就好了,两个点之间可以算出方向,然后根据方向,更新平行光的方向或者位置就可以了,因为three.js中的Sky是绘制出了太阳,但是这个太阳不是光源啊,只是一个自发光。
所有原材料都准备了,那就开干吧
准备一下我们的直射光用来模拟阳光+半球光(可以背面也照亮)、还有太阳的光晕
import {
DirectionalLight,
HemisphereLight,
Color,
PointLight,
TextureLoader,
} from "three";
import {
Lensflare,
LensflareElement,
} from "three/examples/jsm/objects/Lensflare.js";
function createDirLight(dirColor: string | number | Color) {
const dirLight = new DirectionalLight(dirColor);
// 设置直射光阴影
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = Math.pow(2, 13);
dirLight.shadow.mapSize.height = Math.pow(2, 13);
const d = 400;
// 阴影大小
dirLight.shadow.camera.left = -d;
dirLight.shadow.camera.right = d;
dirLight.shadow.camera.top = d;
dirLight.shadow.camera.bottom = -d;
dirLight.shadow.camera.far = 800;
dirLight.shadow.camera.near = 10;
return dirLight;
}
function hlight() {
const hemiLight = new HemisphereLight(0xb1e1ff, "#1e1e1e", 0.4);
hemiLight.color.setHSL(0.6, 1, 0.6);
hemiLight.groundColor.setHSL(0.095, 1, 0.75);
hemiLight.position.set(0, 50, 0);
return hemiLight;
}
function addLight(light: PointLight) {
const lensflare = new Lensflare();
const textureLoader = new TextureLoader();
const textureFlare0 = textureLoader.load("textures/lensflare/lensflare0.png");
const textureFlare3 = textureLoader.load("textures/lensflare/lensflare3.png");
lensflare.addElement(
new LensflareElement(textureFlare0, 250, 0, light.color)
);
lensflare.addElement(new LensflareElement(textureFlare3, 60, 0.6));
lensflare.addElement(new LensflareElement(textureFlare3, 70, 0.7));
lensflare.addElement(new LensflareElement(textureFlare3, 120, 0.9));
lensflare.addElement(new LensflareElement(textureFlare3, 70, 1));
light.add(lensflare);
}
export { createDirLight, hlight, addLight };
还有我们得到的数据是早上8点到下午17点的,但是当我们拖动鼠标滑动时间的时候,变化的值是的范围是8-17只需通过上面的数据,然后再用一些逻辑影响到太阳高度角、太阳方位角、太阳颜色值、太阳光晕的位置等就可以了;
其实就是一些简单的数据转换,例如差值、角度转弧度、球坐标和笛卡尔坐标的互转,这些都可以在代码里看到,从方法名也可以看出来,例如degToRad就是角度转弧度setFromSphericalCoords
球坐标转笛卡尔坐标,multiplyScalar就是缩放向量(一般是知道方向了,然后朝着该放下缩放,得到一个新的位置),感觉写有点啰嗦了,哈哈哈
但是滑动的步长是0.1,那就会出现小数位那怎么呢,我们刚刚的数据可都是整点啊;我们可以通过差值的方式求出这个带有小数位的时间。
three.js给我们提供的很多差值工具例如用于一维的差值MathUtils.lerp,我们时间或者角度都可以用这个,还有颜色差值lerpColors,主要用到这两个,下面直接看代码
<template>
<div class="control">
<div class="top">
<div>{{ }}</div>
<el-button type="primary" @click="showDrawer">
楼栋选择
</el-button>
</div>
<div class="slider">
<span>{{ min + ':00' }}</span>
<el-slider class="bar" v-model="time" :step="0.1" :min="min" :max="max" @input="updateSunPostion" />
<span>{{ max + ':00' }}</span>
<el-button type="primary" :icon="playIcon" round @click="clickHandler" />
</div>
<div class="bnts">
<el-button type="primary" plain :key="season" v-for="(season, index) in seasons" @click="getSeason(index)">
{{ season }}
</el-button>
</div>
</div>
<el-drawer title="选择楼层" v-model="drawer" direction="btt" destroy-on-close width="50%" :append-to-body="true" size="34%"
@close="closeDrawerHandler">
<floorSelectTor @checked="getFloor"></floorSelectTor>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, nextTick, computed, toRaw } from 'vue'
import { ElSlider, ElButton, ElDrawer } from 'element-plus';
import Sun from './index'
import useStore from '../store';
import { Vector3, MathUtils, WebGLRenderer, Scene, Color, PointLight, PerspectiveCamera, DirectionalLight } from 'three'
import { VideoPlay, VideoPause } from '@element-plus/icons-vue'
import { CHANGSHA } from './data'
import { addLight, createDirLight } from './lights'
import floorSelectTor from './floorSelector.vue'
import TWEEN from '@tweenjs/tween.js'
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
const store = useStore()
const drawer = ref(false)
const showDrawer = () => {
drawer.value = true
store.buildingSet.forEach((item: any) => {
item.material.opacity = 1;
})
}
let targetFloor = 0
const getFloor = (floor: number) => {
targetFloor = floor
}
const closeDrawerHandler = () => {
const targetBuilding = scene.getObjectByName('text' + targetFloor + '#')
if (targetBuilding) {
new TWEEN.Tween(control.target)
.to(targetBuilding.position)
.easing(TWEEN.Easing.Quadratic.InOut)
.start();
store.moveViwer(targetFloor + '#')
}
}
let renderer: WebGLRenderer
let scene: Scene
let camera: PerspectiveCamera
let control: OrbitControls
let sun: Sun
const playState = ref(false)
const clickHandler = () => {
if (playState.value) {
cancelAnimationFrame(frame)
} else {
paly()
}
playState.value = !playState.value
}
const playIcon = computed(() => {
return playState.value ? VideoPause : VideoPlay
})
let frame: any
const time = ref(8)
const [min, max] = [8, 17] // 时间
const paly = () => {
time.value += 0.1
updateSunPostion()
if (time.value >= max) {
time.value = 8
}
frame = requestAnimationFrame(paly)
}
const dirColor = new Color('#fdd885')
const morningColor = new Color('#AE8110')
const noonColor = new Color('#f9db56')
// 背面光
const backlight = new DirectionalLight('#89927d', 0.4)
// 光晕效果
const lenPosiotn = new Vector3()
const Plight = new PointLight(dirColor, 0.2, 1500, 0);
const sunDir = new Vector3() // 太阳光位置
const hlightPosition = new Vector3() // 背面光位置
const seasons = ['春分', '夏至', '秋分', '冬至']
let SphericalCoords = CHANGSHA[0]
function getSeason(index: number) {
SphericalCoords = CHANGSHA[index]
updateSunPostion()
}
function updateSunPostion() {
const index = time.value - 8
const curent = Math.floor(index)
const next = Math.ceil(index)
// 太阳高度角
const phi = MathUtils.degToRad(90 - MathUtils.lerp(SphericalCoords[0][curent], SphericalCoords[0][next], index - curent))
// 太阳方位角
const theta = MathUtils.degToRad(180 - MathUtils.lerp(SphericalCoords[1][curent], SphericalCoords[1][next], index - curent))
const ratio = (time.value - min) / (max - min)
const turbidity = ratio * 20
const rayleigh = Math.abs(ratio - 0.5) * 4
sunDir.setFromSphericalCoords(1, phi, theta);
lenPosiotn.setFromSphericalCoords(800, phi, theta)
Plight.position.copy(lenPosiotn)
// 背面光位置
hlightPosition.setFromSphericalCoords(800, Math.PI / 2 - phi, Math.PI - theta)
backlight.position.copy(hlightPosition)
// 太阳颜色
const colorRatio = Math.abs(ratio - 0.5) * 2
Plight.color.set(dirColor.lerpColors(noonColor, morningColor, colorRatio))
// 直射光
dirLight.color.set(dirColor.lerpColors(noonColor, morningColor, colorRatio))
dirLight.position.copy(sunDir.multiplyScalar(350))
const uniforms = sun.sky.material.uniforms;
uniforms["turbidity"].value = turbidity;
uniforms["rayleigh"].value = rayleigh;
uniforms["mieCoefficient"].value = 0.005;
uniforms["mieDirectionalG"].value = ratio;
uniforms["sunPosition"].value.copy(sunDir);
renderer.render(scene, camera)
}
const dirLight = createDirLight(new Color(0.08, 0.8, 0.5))
nextTick(() => {
scene = toRaw(store.scene)
renderer = toRaw(store.renderer)
camera = toRaw(store.camera)
control = toRaw(store.control)
sun = new Sun(scene, renderer)
scene.add(dirLight);
addLight(Plight);
scene.add(Plight)
scene.add(backlight)
updateSunPostion()
})
</script>
<style scoped>
.control {
height: 200px;
width: min(100%, 500px);
background-color: rgba(203, 217, 110, 0.4);
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
border-radius: 10px;
overflow: hidden;
z-index: 2;
}
.slider {
width: 90%;
display: flex;
justify-content: space-between;
line-height: 35px;
margin: 0 20px;
}
.bar {
width: 70%;
}
.bnts {
display: flex;
justify-content: space-around;
align-items: center;
width: 100%;
margin-top: 10px;
}
.top {
width: 90%;
display: flex;
justify-content: space-around;
align-items: center;
font-size: 22px;
color: black;
font-weight: 600;
margin: 10px 20px;
}
</style>
里面的场景初始化或者循环播放啥的不是核心就不过多的啰嗦啦,看最终效果