👨⚕️ 主页: gis分享者
👨⚕️ 感谢各位大佬 点赞👍 收藏⭐ 留言📝 加关注✅!
👨⚕️ 收录于专栏:threejs gis工程师
文章目录
一、🍀前言
本文详细介绍如何基于threejs在三维场景中实现超炫银河黑洞效果模拟,亲测可用。希望能帮助到您。一起学习,加油!加油!
1.1 ☘️THREE.EffectComposer 后期处理
THREE.EffectComposer 用于在three.js中实现后期处理效果。该类管理了产生最终视觉效果的后期处理过程链。 后期处理过程根据它们添加/插入的顺序来执行,最后一个过程会被自动渲染到屏幕上。
1.1.1 ☘️代码示例
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
// 初始化 composer
const composer = new EffectComposer(renderer);
// 创建 RenderPass 并添加到 composer
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
// 添加其他后期处理通道(如模糊)
// composer.addPass(blurPass);
// 在动画循环中渲染
function animate() {
composer.render();
requestAnimationFrame(animate);
}
1.1.2 ☘️构造函数
EffectComposer( renderer : WebGLRenderer, renderTarget : WebGLRenderTarget )
renderer – 用于渲染场景的渲染器。
renderTarget – (可选)一个预先配置的渲染目标,内部由 EffectComposer 使用。
1.1.3 ☘️属性
.passes : Array
一个用于表示后期处理过程链(包含顺序)的数组。
渲染通道:
BloomPass 该通道会使得明亮区域参入较暗的区域。模拟相机照到过多亮光的情形
DotScreenPass 将一层黑点贴到代表原始图片的屏幕上
FilmPass 通过扫描线和失真模拟电视屏幕
MaskPass 在当前图片上贴一层掩膜,后续通道只会影响被贴的区域
RenderPass 该通道在指定的场景和相机的基础上渲染出一个新的场景
SavePass 执行该通道时,它会将当前渲染步骤的结果复制一份,方便后面使用。这个通道实际应用中作用不大;
ShaderPass 使用该通道你可以传入一个自定义的着色器,用来生成高级的、自定义的后期处理通道
TexturePass 该通道可以将效果组合器的当前状态保存为一个纹理,然后可以在其他EffectCoposer对象中将该纹理作为输入参数
.readBuffer : WebGLRenderTarget
内部读缓冲区的引用。过程一般从该缓冲区读取先前的渲染结果。
.renderer : WebGLRenderer
内部渲染器的引用。
.renderToScreen : Boolean
最终过程是否被渲染到屏幕(默认帧缓冲区)。
.writeBuffer : WebGLRenderTarget
内部写缓冲区的引用。过程常将它们的渲染结果写入该缓冲区。
1.1.4 ☘️方法
.addPass ( pass : Pass ) : undefined
pass – 将被添加到过程链的过程
将传入的过程添加到过程链。
.dispose () : undefined
释放此实例分配的 GPU 相关资源。每当您的应用程序不再使用此实例时调用此方法。
.insertPass ( pass : Pass, index : Integer ) : undefined
pass – 将被插入到过程链的过程。
index – 定义过程链中过程应插入的位置。
将传入的过程插入到过程链中所给定的索引处。
.isLastEnabledPass ( passIndex : Integer ) : Boolean
passIndex – 被用于检查的过程
如果给定索引的过程在过程链中是最后一个启用的过程,则返回true。 由EffectComposer所使用,来决定哪一个过程应当被渲染到屏幕上。
.removePass ( pass : Pass ) : undefined
pass – 要从传递链中删除的传递。
从传递链中删除给定的传递。
.render ( deltaTime : Float ) : undefined
deltaTime – 增量时间值。
执行所有启用的后期处理过程,来产生最终的帧,
.reset ( renderTarget : WebGLRenderTarget ) : undefined
renderTarget – (可选)一个预先配置的渲染目标,内部由 EffectComposer 使用。
重置所有EffectComposer的内部状态。
.setPixelRatio ( pixelRatio : Float ) : undefined
pixelRatio – 设备像素比
设置设备的像素比。该值通常被用于HiDPI设备,以阻止模糊的输出。 因此,该方法语义类似于WebGLRenderer.setPixelRatio()。
.setSize ( width : Integer, height : Integer ) : undefined
width – EffectComposer的宽度。
height – EffectComposer的高度。
考虑设备像素比,重新设置内部渲染缓冲和过程的大小为(width, height)。 因此,该方法语义类似于WebGLRenderer.setSize()。
.swapBuffers () : undefined
交换内部的读/写缓冲。
1.2 ☘️THREE.RenderPass
THREE.RenderPass用于将场景渲染到中间缓冲区,为后续的后期处理效果(如模糊、色调调整等)提供基础。
1.2.1 ☘️构造函数
RenderPass(scene, camera, overrideMaterial, clearColor, clearAlpha)
- scene THREE.Scene 要渲染的 Three.js 场景对象。
- camera THREE.Camera 场景对应的相机(如 PerspectiveCamera)。
- overrideMaterial THREE.Material (可选) 覆盖场景中所有物体的材质(默认 null)。
- clearColor THREE.Color (可选) 渲染前清除画布的颜色(默认不主动清除)。
- clearAlpha number (可选) 清除画布的透明度(默认 0)。
1.2.2 ☘️属性
.enabled:boolean
是否启用此通道(默认 true)。设为 false 可跳过渲染。
.clear:boolean
渲染前是否清除画布(默认 true)。若需叠加多个 RenderPass,可设为 false。
.needsSwap:boolean
是否需要在渲染后交换缓冲区(通常保持默认 false)。
1.2.3 ☘️方法
.setSize(width, height)
调整通道的渲染尺寸(通常由 EffectComposer 自动调用)。
width: 画布宽度(像素)。
height: 画布高度(像素)。
1.3 ☘️THREE.UnrealBloomPass
THREE.UnrealBloomPass是 是 Three.js 中用于实现 虚幻引擎风格泛光效果(Bloom) 的后期处理通道。它通过模拟光线散射和光晕效果,增强场景中高光区域的视觉表现。
1.3.1 ☘️构造函数
THREE.UnrealBloomPass(
new THREE.Vector2(width, height), // 渲染目标尺寸(通常与画布一致)
strength, // 泛光强度 (默认 1)
radius, // 泛光半径 (默认 0)
threshold // 泛光阈值 (默认 0)
)
new THREE.Vector2(width, height)
渲染目标的分辨率,通常与画布尺寸一致(如 new THREE.Vector2(window.innerWidth, window.innerHeight))。
strength(强度)
控制泛光效果的强度(亮度)。值越大,泛光越明显。
范围:0(无效果)到 3(强烈)。
radius(半径)
控制泛光的扩散半径。值越大,光晕范围越广。
范围:0(无扩散)到 1(较大扩散)。
threshold(阈值)
仅对亮度高于此值的像素应用泛光。值越低,更多区域会被处理。
范围:0(所有像素)到 1(仅最亮像素)。
1.3.2 ☘️使用示例
// 初始化
const composer = new THREE.EffectComposer(renderer);
const renderPass = new THREE.RenderPass(scene, camera);
const bloomPass = new THREE.UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.2, 0.2, 0.8
);
// 添加通道
composer.addPass(renderPass);
composer.addPass(bloomPass);
// 渲染
function animate() {
requestAnimationFrame(animate);
composer.render();
}
1.3.3 ☘️方法
.setSize(width, height)
调整通道的渲染尺寸(通常由 EffectComposer 自动调用)。
width: 画布宽度(像素)。
height: 画布高度(像素)。
.render(renderer, writeBuffer, readBuffer, deltaTime, maskActive)
内部方法,通常由 EffectComposer 自动调用,无需手动执行。
二、🍀超炫银河黑洞效果模拟
1. ☘️实现思路
通过EffectComposer后期处理组合器,RenderPass、UnrealBloomPass(泛光)后期处理通道,以及自定义shader着色器实现银河黑洞效果。具体代码参考代码样例。可以直接运行。
2. ☘️代码样例
<!DOCTYPE html>
<html lang="en">
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000003;
color: #fff;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#info {
position: absolute;
top: 15px;
width: 100%;
text-align: center;
color: rgba(255, 255, 255, 0.9);
font-size: 18px;
letter-spacing: 0.5px;
pointer-events: none;
z-index: 100;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
transition: opacity 1.5s ease-in-out 1s;
}
.ui-panel {
position: absolute;
background-color: rgba(25, 30, 50, 0.5);
backdrop-filter: blur(10px) saturate(180%);
-webkit-backdrop-filter: blur(10px) saturate(180%);
padding: 12px 15px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(220, 220, 255, 0.9);
font-size: 14px;
user-select: none;
z-index: 50;
opacity: 0.8;
transition: opacity 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease, bottom 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
}
.ui-panel:hover {
opacity: 1;
background-color: rgba(35, 40, 60, 0.6);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
#controls {
bottom: 20px;
right: 20px;
align-items: flex-start;
}
.control-item {
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
transition: color 0.2s ease;
}
.control-item:hover {
color: #fff;
}
#theme-changer {
bottom: 20px;
left: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
#theme-changer h4 {
margin: 0 0 5px 0;
font-weight: 500;
font-size: 13px;
color: rgba(200, 200, 230, 0.8);
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 5px;
}
#theme-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.theme-button {
padding: 6px 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background-color: rgba(255, 255, 255, 0.1);
color: rgba(220, 220, 255, 0.85);
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
text-align: center;
}
.theme-button:hover {
background-color: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.4);
}
.theme-button.active {
background-color: rgba(120, 120, 255, 0.3);
border-color: rgba(150, 150, 255, 0.6);
color: #fff;
font-weight: 500;
transform: scale(1.02);
}
.theme-button:active {
transform: scale(0.98);
}
.ui-icon {
width: 1em;
height: 1em;
stroke: currentColor;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (max-width: 640px) {
.ui-panel {
padding: 10px 12px;
}
#controls {
bottom: 105px;
right: 15px;
max-width: calc(50% - 25px);
min-width: 140px;
}
#theme-changer {
bottom: 15px;
left: 15px;
max-width: calc(50% - 25px);
min-width: 130px;
}
#theme-buttons {
justify-content: flex-start;
}
.theme-button {
padding: 5px 10px;
font-size: 11px;
}
#info {
font-size: 16px;
}
#info span {
font-size: 12px;
}
}
@media (max-width: 380px) {
#controls {
bottom: auto;
top: 15px;
right: 15px;
left: auto;
max-width: calc(100% - 30px);
}
#theme-changer {
bottom: 15px;
left: 15px;
right: 15px;
max-width: none;
width: calc(100% - 30px);
}
#theme-buttons {
justify-content: center;
}
}
</style>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="info">
Galactic Black Hole<br>
<span style="font-size: 14px; opacity: 0.8;">Click core or button for Disk Echo. Drag to rotate.</span>
</div>
<div id="controls" class="ui-panel">
<div id="autoRotateToggle" class="control-item" title="Toggle automatic rotation">
</div>
<div id="triggerEffectButton" class="control-item" title="Trigger Disk Echo">
</div>
</div>
<div id="theme-changer" class="ui-panel">
<h4>Color Theme</h4>
<div id="theme-buttons">
<button class="theme-button active" data-theme="inferno">Inferno</button>
<button class="theme-button" data-theme="ruby">Ruby</button>
<button class="theme-button" data-theme="plasma">Plasma</button>
<button class="theme-button" data-theme="void">Void</button>
</div>
</div>
<script type="module">
import * as THREE from "https://esm.sh/three";
import {
OrbitControls
} from 'https://esm.sh/three/examples/jsm/controls/OrbitControls.js';
import {
EffectComposer
} from 'https://esm.sh/three/examples/jsm/postprocessing/EffectComposer.js';
import {
RenderPass
} from 'https://esm.sh/three/examples/jsm/postprocessing/RenderPass.js';
import {
UnrealBloomPass
} from 'https://esm.sh/three/examples/jsm/postprocessing/UnrealBloomPass.js';
const BLACK_HOLE_EVENT_HORIZON_RADIUS = 1.0;
const DISK_INNER_RADIUS = BLACK_HOLE_EVENT_HORIZON_RADIUS + 0.15;
const DISK_OUTER_RADIUS = 5.5;
const LENSING_SPHERE_RADIUS = BLACK_HOLE_EVENT_HORIZON_RADIUS + 0.07;
const GLOW_RADIUS_FACTOR = 1.07;
const PHOTON_SPHERE_RADIUS = BLACK_HOLE_EVENT_HORIZON_RADIUS * 1.5;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000004, 0.085);
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 2000
);
const renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance"
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.95;
document.body.appendChild(renderer.domElement);
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.7,
0.7,
0.75
);
composer.addPass(bloomPass);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.04;
controls.rotateSpeed = 0.6;
controls.autoRotate = false;
controls.autoRotateSpeed = 0.12;
controls.target.set(0, 0, 0);
controls.minDistance = 2.5;
controls.maxDistance = 100;
controls.enablePan = false;
let autoRotateEnabled = false;
const autoRotateToggle = document.getElementById('autoRotateToggle');
const rotateIconSVG = `<svg class="ui-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23 4v6h-6"></path><path d="M1 20v-6h6"></path><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>`;
function updateAutoRotateText() {
autoRotateToggle.innerHTML = rotateIconSVG + `<span>Auto-Rotate: ${autoRotateEnabled ? "ON" : "OFF"}</span>`;
}
updateAutoRotateText();
autoRotateToggle.addEventListener('click', () => {
autoRotateEnabled = !autoRotateEnabled;
controls.autoRotate = autoRotateEnabled;
updateAutoRotateText();
});
const triggerEffectButton = document.getElementById('triggerEffectButton');
const effectIconSVG = `<svg class="ui-icon" viewBox="0 0 24 24" style="stroke-width:1.5;" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="2"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="8"/></svg>`;
triggerEffectButton.innerHTML = effectIconSVG + `<span>Disk Echo</span>`;
triggerEffectButton.addEventListener('click', () => {
triggerDiskEcho();
});
const starGeometry = new THREE.BufferGeometry();
const starCount = 45000;
const starPositions = new Float32Array(starCount * 3);
const starColors = new Float32Array(starCount * 3);
const starSizes = new Float32Array(starCount);
const starAlphas = new Float32Array(starCount);
const starFieldRadius = 1200;
const baseColor = new THREE.Color(0xffffff);
const blueColor = new THREE.Color(0xaaddff);
const yellowColor = new THREE.Color(0xffffaa);
const redColor = new THREE.Color(0xffcccc);
for (let i = 0; i < starCount; i++) {
const i3 = i * 3;
const goldenRatio = (1 + Math.sqrt(5)) / 2;
const theta = 2 * Math.PI * i / goldenRatio;
const phi = Math.acos(1 - 2 * (i + 0.5) / starCount);
const radius = Math.cbrt(Math.random()) * starFieldRadius;
starPositions[i3] = radius * Math.sin(phi) * Math.cos(theta);
starPositions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
starPositions[i3 + 2] = radius * Math.cos(phi);
const starColor = baseColor.clone();
const colorType = Math.random();
let colorIntensity = Math.random() * 0.4 + 0.6;
if (colorType < 0.5) {
starColor.lerp(blueColor, Math.random() * 0.3);
} else if (colorType < 0.85) {
starColor.lerp(yellowColor, Math.random() * 0.2);
colorIntensity *= 0.9;
} else {
starColor.lerp(redColor, Math.random() * 0.15);
colorIntensity *= 0.8;
}
starColor.multiplyScalar(colorIntensity);
starColors[i3] = starColor.r;
starColors[i3 + 1] = starColor.g;
starColors[i3 + 2] = starColor.b;
const sizeVariation = Math.random();
if (sizeVariation > 0.997) {
starSizes[i] = THREE.MathUtils.randFloat(1.5, 2.2);
} else if (sizeVariation > 0.98) {
starSizes[i] = THREE.MathUtils.randFloat(0.8, 1.5);
} else {
starSizes[i] = THREE.MathUtils.randFloat(0.3, 0.8);
}
const distFactor = Math.min(1.0, radius / starFieldRadius);
starSizes[i] *= (1.0 - distFactor * 0.3);
starAlphas[i] = Math.random() * 0.5 + 0.5;
}
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
starGeometry.setAttribute('color', new THREE.BufferAttribute(starColors, 3));
starGeometry.setAttribute('size', new THREE.BufferAttribute(starSizes, 1));
starGeometry.setAttribute('alpha', new THREE.BufferAttribute(starAlphas, 1));
const starMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: {
value: 0.0
},
uDiskEchoActive: {
value: 0.0
},
uDiskEchoIntensity: {
value: 0.0
}
},
vertexShader: `
attribute float size;
attribute float alpha;
varying vec3 vColor;
varying float vAlpha;
uniform float uDiskEchoActive;
uniform float uDiskEchoIntensity;
void main() {
vColor = color;
vAlpha = alpha;
vec3 adjustedPosition = position;
if (uDiskEchoActive > 0.0) {
float distFromCenter = length(position);
float pushFactor = uDiskEchoIntensity * 0.025 * smoothstep(50.0, 300.0, distFromCenter);
adjustedPosition = position * (1.0 + pushFactor);
}
vec4 mvPosition = modelViewMatrix * vec4(adjustedPosition, 1.0);
gl_PointSize = size * (350.0 / -mvPosition.z) * (1.0 + uDiskEchoIntensity * 0.35);
gl_Position = projectionMatrix * mvPosition;
}`,
fragmentShader: `
uniform float uTime;
uniform float uDiskEchoIntensity;
varying vec3 vColor;
varying float vAlpha;
void main() {
float r = length(gl_PointCoord - vec2(0.5, 0.5));
float baseAlpha = 1.0 - smoothstep(0.45, 0.5, r);
if (baseAlpha < 0.01) discard;
float twinkleSpeed = vAlpha * 1.5 + 0.5 + uDiskEchoIntensity * 4.0;
float twinkleRange = 0.15 + uDiskEchoIntensity * 0.4;
float twinkle = sin(uTime * twinkleSpeed + vAlpha * 10.0) * twinkleRange + 0.9;
vec3 finalColor = vColor * twinkle * (1.0 + uDiskEchoIntensity * 0.9);
gl_FragColor = vec4(finalColor, baseAlpha * vAlpha * (1.0 + uDiskEchoIntensity * 0.45));
}`,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
vertexColors: true
});
const stars = new THREE.Points(starGeometry, starMaterial);
scene.add(stars);
const blackHoleGeometry = new THREE.SphereGeometry(BLACK_HOLE_EVENT_HORIZON_RADIUS, 64, 32);
const blackHoleMaterial = new THREE.MeshBasicMaterial({
color: 0x000000
});
const blackHole = new THREE.Mesh(blackHoleGeometry, blackHoleMaterial);
blackHole.renderOrder = 0;
scene.add(blackHole);
const themes = {
inferno: {
diskHot: new THREE.Color(0xffffff),
diskMid: new THREE.Color(0xffaa33),
diskEdge: new THREE.Color(0xcc331a),
diskDeep: new THREE.Color(0x661a00),
lensing: new THREE.Color(0xffcc66),
glow: new THREE.Color(0xff8833),
photonSphere: new THREE.Color(0xffbb44),
primaryWave: new THREE.Color(0xffaa33),
secondaryWave: new THREE.Color(0xff5500),
tertiaryWave: new THREE.Color(0xffdd22)
},
ruby: {
diskHot: new THREE.Color(0xFFE4E1),
diskMid: new THREE.Color(0xE0115F),
diskEdge: new THREE.Color(0x8B0000),
diskDeep: new THREE.Color(0x550000),
lensing: new THREE.Color(0xFF6347),
glow: new THREE.Color(0xFF4500),
photonSphere: new THREE.Color(0xFF7F50),
primaryWave: new THREE.Color(0xFF4500),
secondaryWave: new THREE.Color(0xE0115F),
tertiaryWave: new THREE.Color(0xFF6347)
},
plasma: {
diskHot: new THREE.Color(0xffffff),
diskMid: new THREE.Color(0x66ff66),
diskEdge: new THREE.Color(0x00cc4d),
diskDeep: new THREE.Color(0x006626),
lensing: new THREE.Color(0x99ff99),
glow: new THREE.Color(0x66ff99),
photonSphere: new THREE.Color(0x88ffaa),
primaryWave: new THREE.Color(0x66ff99),
secondaryWave: new THREE.Color(0x22ffaa),
tertiaryWave: new THREE.Color(0xaaffcc)
},
void: {
diskHot: new THREE.Color(0xffffff),
diskMid: new THREE.Color(0x87cefa),
diskEdge: new THREE.Color(0x1e90ff),
diskDeep: new THREE.Color(0x00008b),
lensing: new THREE.Color(0xb0e0e6),
glow: new THREE.Color(0xadd8e6),
photonSphere: new THREE.Color(0x99ccff),
primaryWave: new THREE.Color(0xadd8e6),
secondaryWave: new THREE.Color(0x1e90ff),
tertiaryWave: new THREE.Color(0xb0e0e6)
}
};
let currentThemeName = 'inferno';
let currentTheme = themes[currentThemeName];
const diskGeometry = new THREE.RingGeometry(DISK_INNER_RADIUS, DISK_OUTER_RADIUS, 128, 64);
const diskMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: {
value: 0
},
uColorHot: {
value: new THREE.Color().copy(currentTheme.diskHot)
},
uColorMid: {
value: new THREE.Color().copy(currentTheme.diskMid)
},
uColorEdge: {
value: new THREE.Color().copy(currentTheme.diskEdge)
},
uColorDeep: {
value: new THREE.Color().copy(currentTheme.diskDeep)
},
uCameraPosition: {
value: camera.position
},
uRippleActive: {
value: 0.0
},
uRippleStartTime: {
value: 0.0
},
uRippleDuration: {
value: 2.8
},
uPrimaryWaveColor: {
value: new THREE.Color(currentTheme.primaryWave)
},
uSecondaryWaveColor: {
value: new THREE.Color(currentTheme.secondaryWave)
},
uTertiaryWaveColor: {
value: new THREE.Color(currentTheme.tertiaryWave)
},
uRippleMaxRadius: {
value: DISK_OUTER_RADIUS
},
uRippleThickness: {
value: DISK_OUTER_RADIUS * 0.12
},
uRippleIntensity: {
value: 0.0
},
uRippleDistortionStrength: {
value: 0.0
}
},
vertexShader: `
varying vec2 vUv;
varying vec3 vPosition;
varying float vRadius;
uniform float uRippleDistortionStrength;
uniform float uTime;
void main() {
vUv = uv;
vPosition = position;
vRadius = length(position.xy);
vec3 adjustedPosition = position;
if (uRippleDistortionStrength > 0.0) {
float angle = atan(position.y, position.x);
float distortionAmount = sin(angle * 10.0 + uTime * 7.0 + vRadius * 2.0) * 0.08 * uRippleDistortionStrength;
adjustedPosition.z += distortionAmount;
}
gl_Position = projectionMatrix * modelViewMatrix * vec4(adjustedPosition, 1.0);
}`,
fragmentShader: `
uniform float uTime;
uniform vec3 uColorHot;
uniform vec3 uColorMid;
uniform vec3 uColorEdge;
uniform vec3 uColorDeep;
uniform vec3 uCameraPosition;
varying vec2 vUv;
varying vec3 vPosition;
varying float vRadius;
uniform float uRippleActive;
uniform float uRippleStartTime;
uniform float uRippleDuration;
uniform vec3 uPrimaryWaveColor;
uniform vec3 uSecondaryWaveColor;
uniform vec3 uTertiaryWaveColor;
uniform float uRippleMaxRadius;
uniform float uRippleThickness;
uniform float uRippleIntensity;
float rand(vec2 n){return fract(sin(dot(n,vec2(12.9898,4.1414)))*43758.5453);}
float noise(vec2 p){
vec2 ip=floor(p);
vec2 u=fract(p);
u=u*u*(3.0-2.0*u);
float res=mix(mix(rand(ip),rand(ip+vec2(1.0,0.0)),u.x),mix(rand(ip+vec2(0.0,1.0)),rand(ip+vec2(1.0,1.0)),u.x),u.y);
return res*res;
}
float fbm(vec2 p, float timeOffset, float freq, int octaves) {
float total=0.0;
float amplitude=0.65;
float persistence=0.5;
for(int i=0;i<octaves;i++){
float timeScale=0.6+0.12*float(i);
float noiseVal = noise(p*freq+vec2(timeOffset*timeScale*0.45,timeOffset*timeScale*0.3));
total+=amplitude*noiseVal;
vec2 warpOffset=vec2(noiseVal*0.18,-noiseVal*0.12);
p+=warpOffset*amplitude*0.5;
freq*=2.0;
amplitude*=persistence;
}
return total;
}
float vortexPattern(float dist, float angle, float time){
float spiralStrength=5.8;
float timeScale=0.6;
float angleOffset=dist*0.28;
float spiral=sin(angle*2.3+angleOffset+dist*spiralStrength-time*timeScale);
return smoothstep(-0.38,0.68,spiral)*0.32;
}
float calculateRippleIntensity(float dist, float rippleProgress, float currentRippleRadius, float thickness, float speedFactor) {
if (rippleProgress <= 0.0 || rippleProgress >= 1.0) return 0.0;
float distToRippleCenter = abs(dist - currentRippleRadius);
float halfThickness = thickness * 0.5 * mix(1.0, 0.25, rippleProgress);
float waveEnergyFactor = pow(1.0 - rippleProgress, 0.8 * speedFactor);
float waveShape = smoothstep(halfThickness, halfThickness - (thickness * 0.25), distToRippleCenter);
float angle = atan(vPosition.y, vPosition.x);
float angleMod = sin(angle * 10.0 + rippleProgress * 15.0) * 0.15 + 0.9;
return waveShape * waveEnergyFactor * angleMod;
}
void main(){
float dist = vRadius;
float innerEdge = ${DISK_INNER_RADIUS.toFixed(2)};
float outerEdge = ${DISK_OUTER_RADIUS.toFixed(2)};
float normalizedPos = clamp((dist - innerEdge) / (outerEdge - innerEdge), 0.0, 1.0);
float angle = atan(vPosition.y, vPosition.x);
float orbitalVelocity = 1.0 / sqrt(max(dist, 0.1));
float dopplerFactor = 0.0; float beamingFactor = 1.0;
if (length(uCameraPosition) > 0.01) {
vec3 tangentialDirection = normalize(vec3(-vPosition.y, vPosition.x, 0.0));
vec3 toCamera = normalize(uCameraPosition - vPosition);
dopplerFactor = dot(toCamera, tangentialDirection) * orbitalVelocity * 0.3;
beamingFactor = 1.0 + dopplerFactor * 0.4;
beamingFactor = clamp(beamingFactor, 0.5, 2.0);
}
float rotationSpeedFactor = 4.8/(pow(dist,1.6)+1.1);
float rotatedAngle = angle-uTime*rotationSpeedFactor*0.52;
vec2 baseCoord = vec2(dist*1.9, rotatedAngle*3.6);
float evolvingTime = uTime*0.17;
float noiseValueFast = fbm(baseCoord, evolvingTime * 1.2, 2.2, 6);
float noiseValueSlow = fbm(baseCoord * 0.6, evolvingTime * 0.5, 1.5, 4);
float noiseValue = noiseValueFast * 0.7 + noiseValueSlow * 0.4;
float vortexValue = vortexPattern(dist, angle, uTime);
float finalPattern = noiseValue*0.8 + vortexValue*1.1;
float temperature = orbitalVelocity * (1.0 + finalPattern * 0.3);
temperature = clamp(temperature, 0.0, 2.0);
vec3 colorInner = mix(uColorHot, uColorMid, smoothstep(0.0, 0.40, normalizedPos) * (1.0 - temperature * 0.3));
vec3 colorOuterBlend = mix(uColorMid, uColorEdge, smoothstep(0.40, 0.80, normalizedPos));
vec3 colorDeepBlend = mix(uColorEdge, uColorDeep, smoothstep(0.80, 1.0, normalizedPos));
vec3 color = mix(colorInner, colorOuterBlend, smoothstep(0.40, 0.80, normalizedPos));
color = mix(color, colorDeepBlend, smoothstep(0.80, 1.0, normalizedPos));
float redshiftFactor = dopplerFactor * 0.15;
vec3 redshift = vec3(1.0 + redshiftFactor, 1.0, 1.0 - redshiftFactor);
color *= redshift;
float patternBrightness = (finalPattern+0.5)*1.15;
patternBrightness += pow(max(0.0,finalPattern-0.5),1.3)*0.6;
float radialBrightness = pow(1.0-smoothstep(0.0,0.8,normalizedPos),1.9)*3.0+0.25;
float finalBrightness = patternBrightness*radialBrightness*beamingFactor;
float combinedRippleIntensity = 0.0;
vec3 rippleColorContribution = vec3(0.0);
if (uRippleActive > 0.5) {
float rippleTime = uTime - uRippleStartTime;
float rippleProgress = clamp(rippleTime / uRippleDuration, 0.0, 1.0);
float primarySpeed = 1.0;
float primaryRadius = mix(innerEdge, uRippleMaxRadius, rippleProgress * primarySpeed);
float primaryIntensity = calculateRippleIntensity(dist, rippleProgress, primaryRadius, uRippleThickness, primarySpeed);
float secondarySpeed = 0.75;
float secondaryProgress = max(0.0, rippleProgress - 0.1) * secondarySpeed;
float secondaryRadius = mix(innerEdge, uRippleMaxRadius * 0.85, secondaryProgress);
float secondaryIntensity = calculateRippleIntensity(dist, secondaryProgress, secondaryRadius, uRippleThickness * 0.8, secondarySpeed) * 0.8;
float tertiarySpeed = 0.5;
float tertiaryProgress = max(0.0, rippleProgress - 0.2) * tertiarySpeed;
float tertiaryRadius = mix(innerEdge, uRippleMaxRadius * 0.7, tertiaryProgress);
float tertiaryIntensity = calculateRippleIntensity(dist, tertiaryProgress, tertiaryRadius, uRippleThickness * 0.6, tertiarySpeed) * 0.6;
combinedRippleIntensity = primaryIntensity + secondaryIntensity + tertiaryIntensity;
rippleColorContribution = uPrimaryWaveColor * primaryIntensity +
uSecondaryWaveColor * secondaryIntensity +
uTertiaryWaveColor * tertiaryIntensity;
float sparkleNoiseVal = rand(vUv * vec2(300.0, 500.0) + uTime * vec2(20.0 + primaryIntensity * 10.0, 30.0 + primaryIntensity * 15.0) );
float sparkleThreshold = 0.985 - primaryIntensity * 0.03;
if (primaryIntensity > 0.02 && sparkleNoiseVal > sparkleThreshold) {
float sparkleBrightness = pow((sparkleNoiseVal - sparkleThreshold) / (1.0 - sparkleThreshold), 2.0);
rippleColorContribution += mix(uPrimaryWaveColor, vec3(1.0), 0.6) * primaryIntensity * sparkleBrightness * 10.0 * uRippleIntensity;
}
float afterglowPulse = sin(rippleProgress * 15.0) * 0.5 + 0.5;
float afterglowIntensity = smoothstep(0.0, 0.3, rippleProgress) * (1.0 - rippleProgress) * 0.4 * afterglowPulse;
combinedRippleIntensity += afterglowIntensity * smoothstep(innerEdge, innerEdge + 1.5, dist);
}
float rippleBoost = combinedRippleIntensity * 9.0 * uRippleIntensity;
color *= (finalBrightness + rippleBoost);
if (combinedRippleIntensity * uRippleIntensity > 0.01) {
float shimmerEffect = sin(angle * 20.0 + uTime * 10.0 + dist * 5.0) * 0.15 + 0.9;
vec3 currentRippleColors = rippleColorContribution * shimmerEffect;
color = mix(color, currentRippleColors * 1.8, min(1.0, combinedRippleIntensity * uRippleIntensity * 1.5));
}
float hotBoost = smoothstep(3.0, 5.0, finalBrightness + rippleBoost) * smoothstep(0.0, 0.1, normalizedPos);
color = mix(color, vec3(1.0, 1.0, 1.0), hotBoost * 0.45);
float innerAlpha = smoothstep(0.0, 0.06, normalizedPos);
float outerAlpha = 1.0 - smoothstep(0.85, 1.0, normalizedPos);
float noiseAlphaFactor = clamp(finalPattern * 0.35 + 0.75, 0.65, 1.0);
float alpha = innerAlpha * outerAlpha * noiseAlphaFactor;
float rippleAlphaBoost = combinedRippleIntensity * 0.9 * uRippleIntensity;
color = clamp(color, 0.0, 8.0);
gl_FragColor = vec4(color, clamp(alpha + rippleAlphaBoost, 0.0, 1.0));
}`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const accretionDisk = new THREE.Mesh(diskGeometry, diskMaterial);
accretionDisk.rotation.x = Math.PI / 2.6;
accretionDisk.renderOrder = 1;
scene.add(accretionDisk);
const photonSphereGeometry = new THREE.SphereGeometry(PHOTON_SPHERE_RADIUS, 64, 32);
const photonSphereMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: {
value: 0
},
uColor: {
value: new THREE.Color().copy(currentTheme.photonSphere)
},
uDiskEchoActive: {
value: 0.0
},
uDiskEchoIntensity: {
value: 0.0
}
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * mvPosition;
}`,
fragmentShader: `
uniform float uTime;
uniform vec3 uColor;
uniform float uDiskEchoActive;
uniform float uDiskEchoIntensity;
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec3 viewDir = normalize(vViewPosition);
float fresnel = pow(1.0 - abs(dot(viewDir, vNormal)), 3.0);
float pulseRate = 2.0 + uDiskEchoIntensity * 8.0;
float pulseDepth = 0.1 + uDiskEchoIntensity * 0.5;
float pulse = sin(uTime * pulseRate) * pulseDepth + 0.9;
float alpha = fresnel * (0.3 + uDiskEchoIntensity * 0.6) * pulse;
vec3 finalColor = uColor;
if (uDiskEchoActive > 0.5) {
float colorPulse = sin(uTime * 4.0 + dot(vNormal, vec3(1.0)) * 5.0) * 0.5 + 0.5;
finalColor = mix(finalColor, finalColor * vec3(1.4, 1.2, 0.8), colorPulse * uDiskEchoIntensity * 1.2);
finalColor *= (1.0 + uDiskEchoIntensity * 0.7);
}
gl_FragColor = vec4(finalColor, alpha);
}`,
transparent: true,
side: THREE.FrontSide,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const photonSphere = new THREE.Mesh(photonSphereGeometry, photonSphereMaterial);
photonSphere.renderOrder = 4;
scene.add(photonSphere);
const lensingGeometry = new THREE.SphereGeometry(LENSING_SPHERE_RADIUS, 64, 32);
const lensingMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: {
value: 0
},
uLensingColor: {
value: new THREE.Color().copy(currentTheme.lensing)
},
uDiskEchoActive: {
value: 0.0
},
uDiskEchoIntensity: {
value: 0.0
}
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * mvPosition;
}`,
fragmentShader: `
uniform float uTime;
uniform vec3 uLensingColor;
uniform float uDiskEchoActive;
uniform float uDiskEchoIntensity;
varying vec3 vNormal;
varying vec3 vViewPosition;
float fresnel(vec3 d,vec3 n,float p){
return pow(1.0-abs(dot(normalize(d),n)),p);
}
float rand(vec2 n){
return fract(sin(dot(n,vec2(12.9898,4.1414)))*43758.5453);
}
float noise(vec2 p){
vec2 ip=floor(p);
vec2 u=fract(p);
u=u*u*(3.0-2.0*u);
float res=mix(mix(rand(ip),rand(ip+vec2(1.0,0.0)),u.x),mix(rand(ip+vec2(0.0,1.0)),rand(ip+vec2(1.0,1.0)),u.x),u.y);
return res*res;
}
void main(){
vec3 viewDir=normalize(vViewPosition);
float fresnelPower = 5.2 - uDiskEchoIntensity * 1.5;
float fF=fresnel(viewDir,vNormal,fresnelPower);
float pulseSpeed = 0.55 + uDiskEchoIntensity * 3.0;
float pulseDepth = 0.12 + uDiskEchoIntensity * 0.4;
float p=(sin(uTime*pulseSpeed+length(vViewPosition)*0.12)*pulseDepth+0.95);
float noiseScale = 7.0 + uDiskEchoIntensity * 5.0;
float noiseSpeed = 0.35 + uDiskEchoIntensity * 1.2;
vec2 nC=vNormal.xy*noiseScale+uTime*noiseSpeed;
float nV=noise(nC)*(0.12 + uDiskEchoIntensity * 0.15);
vec3 dN=normalize(vNormal+vec3(nV,nV*0.6,0.0));
float alphaBase = 0.68 + uDiskEchoIntensity * 0.5;
float a=fF*alphaBase*p;
float edgePower = 8.5 - uDiskEchoIntensity * 3.5;
a+=pow(1.0-abs(dot(viewDir,dN)),edgePower)*(0.38 + uDiskEchoIntensity * 0.6);
vec3 finalColor = uLensingColor;
if (uDiskEchoActive > 0.5) {
float colorShift = dot(viewDir, vNormal) * 0.5 + 0.5;
finalColor = mix(finalColor, finalColor * vec3(1.3, 1.1, 0.9), colorShift * uDiskEchoIntensity);
finalColor *= (1.0 + uDiskEchoIntensity * 0.4);
}
gl_FragColor=vec4(finalColor, clamp(a,0.0,1.0)*0.90);
}`,
transparent: true,
side: THREE.FrontSide,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const lensingEffectSphere = new THREE.Mesh(lensingGeometry, lensingMaterial);
lensingEffectSphere.scale.multiplyScalar(1.62);
lensingEffectSphere.renderOrder = 2;
scene.add(lensingEffectSphere);
const glowGeometry = new THREE.SphereGeometry(BLACK_HOLE_EVENT_HORIZON_RADIUS, 64, 32);
const glowMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: {
value: 0
},
uGlowColor: {
value: new THREE.Color().copy(currentTheme.glow)
},
uDiskEchoActive: {
value: 0.0
},
uDiskEchoIntensity: {
value: 0.0
},
uDiskEchoColor: {
value: new THREE.Color().copy(currentTheme.primaryWave)
}
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * mvPosition;
}`,
fragmentShader: `
uniform float uTime;
uniform vec3 uGlowColor;
uniform float uDiskEchoActive;
uniform float uDiskEchoIntensity;
uniform vec3 uDiskEchoColor;
varying vec3 vNormal;
varying vec3 vViewPosition;
float rand(vec2 n){
return fract(sin(dot(n,vec2(12.9898,4.1414)))*43758.5453);
}
float noise(vec2 p){
vec2 ip=floor(p);
vec2 u=fract(p);
u=u*u*(3.0-2.0*u);
float res=mix(mix(rand(ip),rand(ip+vec2(1.0,0.0)),u.x),mix(rand(ip+vec2(0.0,1.0)),rand(ip+vec2(1.0,1.0)),u.x),u.y);
return res*res;
}
void main(){
float glowPower = 2.6 - uDiskEchoIntensity * 1.2;
float i=pow(0.68-dot(vNormal,normalize(vViewPosition)), glowPower);
float pulseSpeed = 0.7 + uDiskEchoIntensity * 7.0;
float pulseDepth = 0.18 + uDiskEchoIntensity * 0.5;
float p=sin(uTime*pulseSpeed+vNormal.y*1.8)*pulseDepth+0.88;
float noiseScale = 9.0 + uDiskEchoIntensity * 8.0;
float noiseSpeed = 1.8 + uDiskEchoIntensity * 6.0;
float f=noise(vNormal.xz*noiseScale+uTime*noiseSpeed)*(0.35 + uDiskEchoIntensity * 0.25)+0.75;
float fI=clamp(i*p*f,0.0,1.0)*(0.92 + uDiskEchoIntensity * 0.5);
vec3 finalColor = uGlowColor;
if (uDiskEchoActive > 0.5) {
float flarePattern = noise(vNormal.xy * 15.0 + uTime * 3.0) * noise(vNormal.yz * 12.0 + uTime * 2.0);
float flarePulse = sin(uTime * 8.0 + flarePattern * 10.0) * 0.5 + 0.5;
vec3 flareColor = mix(uGlowColor, uDiskEchoColor, flarePulse);
finalColor = mix(uGlowColor, flareColor * 1.8, uDiskEchoIntensity * flarePulse * 1.2);
finalColor *= (1.0 + uDiskEchoIntensity * 0.8);
}
gl_FragColor=vec4(finalColor, fI);
}`,
transparent: true,
side: THREE.BackSide,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const glowEffect = new THREE.Mesh(glowGeometry, glowMaterial);
glowEffect.scale.multiplyScalar(GLOW_RADIUS_FACTOR * 1.16);
glowEffect.renderOrder = 3;
scene.add(glowEffect);
let lastRippleTime = -Infinity;
const RIPPLE_COOLDOWN = 0.5;
let diskEchoIntensity = 0.0;
let diskEchoActive = false;
let diskEchoStartTime = 0;
const DISK_ECHO_DURATION = 2.8;
function triggerDiskEcho() {
const currentTime = clock.getElapsedTime();
if (currentTime - lastRippleTime < RIPPLE_COOLDOWN) {
return;
}
lastRippleTime = currentTime;
diskEchoStartTime = currentTime;
diskEchoActive = true;
diskMaterial.uniforms.uRippleActive.value = 1.0;
diskMaterial.uniforms.uRippleStartTime.value = currentTime;
diskMaterial.uniforms.uPrimaryWaveColor.value.copy(themes[currentThemeName].primaryWave).multiplyScalar(3.0);
diskMaterial.uniforms.uSecondaryWaveColor.value.copy(themes[currentThemeName].secondaryWave).multiplyScalar(2.7);
diskMaterial.uniforms.uTertiaryWaveColor.value.copy(themes[currentThemeName].tertiaryWave).multiplyScalar(2.4);
glowMaterial.uniforms.uDiskEchoColor.value.copy(themes[currentThemeName].primaryWave).multiplyScalar(1.8);
bloomPass.strength = 1.3;
bloomPass.threshold = 0.60;
}
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function onPointerDown(event) {
if (event.target.closest('.ui-panel')) return;
if (event.isPrimary === false && event.pointerType !== 'touch') return;
let x, y;
if (event.touches && event.touches.length > 0) {
x = event.touches[0].clientX;
y = event.touches[0].clientY;
} else {
x = event.clientX;
y = event.clientY;
}
pointer.x = (x / window.innerWidth) * 2 - 1;
pointer.y = -(y / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObject(blackHole, false);
if (intersects.length > 0) triggerDiskEcho();
}
renderer.domElement.addEventListener('pointerdown', onPointerDown, false);
const themeButtonsContainer = document.getElementById('theme-buttons');
themeButtonsContainer.addEventListener('click', (event) => {
const button = event.target.closest('.theme-button');
if (button) {
const themeName = button.dataset.theme;
if (themes[themeName] && themeName !== currentThemeName) {
currentThemeName = themeName;
currentTheme = themes[currentThemeName];
diskMaterial.uniforms.uColorHot.value.copy(currentTheme.diskHot);
diskMaterial.uniforms.uColorMid.value.copy(currentTheme.diskMid);
diskMaterial.uniforms.uColorEdge.value.copy(currentTheme.diskEdge);
diskMaterial.uniforms.uColorDeep.value.copy(currentTheme.diskDeep);
lensingMaterial.uniforms.uLensingColor.value.copy(currentTheme.lensing);
glowMaterial.uniforms.uGlowColor.value.copy(currentTheme.glow);
photonSphereMaterial.uniforms.uColor.value.copy(currentTheme.photonSphere);
diskMaterial.uniforms.uPrimaryWaveColor.value.copy(currentTheme.primaryWave).multiplyScalar(3.0);
diskMaterial.uniforms.uSecondaryWaveColor.value.copy(currentTheme.secondaryWave).multiplyScalar(2.7);
diskMaterial.uniforms.uTertiaryWaveColor.value.copy(currentTheme.tertiaryWave).multiplyScalar(2.4);
glowMaterial.uniforms.uDiskEchoColor.value.copy(currentTheme.primaryWave).multiplyScalar(1.8);
themeButtonsContainer.querySelectorAll('.theme-button').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
}
}
});
setTimeout(() => {
const info = document.getElementById('info');
if (info) info.style.opacity = '0';
}, 7000);
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
bloomPass.resolution.set(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
}, 150);
});
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const elapsedTime = clock.getElapsedTime();
const deltaTime = clock.getDelta();
diskMaterial.uniforms.uTime.value = elapsedTime;
lensingMaterial.uniforms.uTime.value = elapsedTime;
glowMaterial.uniforms.uTime.value = elapsedTime;
starMaterial.uniforms.uTime.value = elapsedTime;
photonSphereMaterial.uniforms.uTime.value = elapsedTime;
diskMaterial.uniforms.uCameraPosition.value.copy(camera.position);
if (diskEchoActive) {
const timeSinceEchoStart = elapsedTime - diskEchoStartTime;
const normalizedTime = timeSinceEchoStart / DISK_ECHO_DURATION;
let intensityVal;
if (normalizedTime < 0.07) {
intensityVal = normalizedTime / 0.07;
} else {
const t = (normalizedTime - 0.07) / (1.0 - 0.07);
intensityVal = Math.pow(1.0 - t, 1.8);
intensityVal += Math.sin(t * Math.PI) * 0.35 * Math.pow(1.0 - t, 0.5);
}
diskEchoIntensity = Math.max(0.0, Math.min(1.0, intensityVal));
const pulseFactor = Math.sin(elapsedTime * 15.0) * 0.15 + 1.0;
diskEchoIntensity *= pulseFactor;
diskEchoIntensity = Math.min(1.2, diskEchoIntensity);
let distortionStrengthFactor = 0.0;
if (normalizedTime < 0.4) {
distortionStrengthFactor = Math.sin((normalizedTime / 0.4) * Math.PI);
}
diskMaterial.uniforms.uRippleDistortionStrength.value = distortionStrengthFactor * diskEchoIntensity * 2.0;
if (timeSinceEchoStart >= DISK_ECHO_DURATION) {
diskEchoActive = false;
diskEchoIntensity = 0.0;
diskMaterial.uniforms.uRippleActive.value = 0.0;
diskMaterial.uniforms.uRippleDistortionStrength.value = 0.0;
bloomPass.strength = 0.7;
bloomPass.threshold = 0.75;
}
diskMaterial.uniforms.uRippleIntensity.value = diskEchoIntensity;
starMaterial.uniforms.uDiskEchoActive.value = diskEchoActive ? 1.0 : 0.0;
starMaterial.uniforms.uDiskEchoIntensity.value = diskEchoIntensity;
photonSphereMaterial.uniforms.uDiskEchoActive.value = diskEchoActive ? 1.0 : 0.0;
photonSphereMaterial.uniforms.uDiskEchoIntensity.value = diskEchoIntensity;
lensingMaterial.uniforms.uDiskEchoActive.value = diskEchoActive ? 1.0 : 0.0;
lensingMaterial.uniforms.uDiskEchoIntensity.value = diskEchoIntensity;
glowMaterial.uniforms.uDiskEchoActive.value = diskEchoActive ? 1.0 : 0.0;
glowMaterial.uniforms.uDiskEchoIntensity.value = diskEchoIntensity;
}
controls.update();
stars.rotation.y += deltaTime * 0.004;
stars.rotation.x += deltaTime * 0.0015;
composer.render(deltaTime);
}
function initialCameraAnimation() {
const startPosition = new THREE.Vector3(0, 15, 18);
const endPosition = new THREE.Vector3(0, 5, 8);
const duration = 4500;
const startTime = Date.now();
camera.position.copy(startPosition);
controls.enabled = false;
function updateCamera() {
const elapsed = Date.now() - startTime;
if (elapsed < duration) {
const progress = elapsed / duration;
const t = 1 - Math.pow(1 - progress, 5);
camera.position.lerpVectors(startPosition, endPosition, t);
controls.target.set(0, 0, 0);
requestAnimationFrame(updateCamera);
} else {
camera.position.copy(endPosition);
controls.target.set(0, 0, 0);
controls.enabled = true;
}
}
updateCamera();
}
window.onload = () => {
initialCameraAnimation();
animate();
}
</script>
</body>
</html>
效果如下
参考:Three.js 银河黑洞模拟