学习threejs,打造宇宙星云背景

👨‍⚕️ 主页: gis分享者

👨‍⚕️ 感谢各位大佬 点赞👍 收藏⭐ 留言📝 加关注✅!

👨‍⚕️ 收录于专栏:threejs gis工程师


文章目录

  • 一、🍀前言
    • [1.1 ☘️THREE.ShaderMaterial](#1.1 ☘️THREE.ShaderMaterial)
      • [1.1.1 ☘️注意事项](#1.1.1 ☘️注意事项)
      • [1.1.2 ☘️构造函数](#1.1.2 ☘️构造函数)
      • [1.1.3 ☘️属性](#1.1.3 ☘️属性)
      • [1.1.4 ☘️方法](#1.1.4 ☘️方法)
    • [1.2 ☘️BufferGeometry缓冲几何体](#1.2 ☘️BufferGeometry缓冲几何体)
      • [1.2.1 构造函数](#1.2.1 构造函数)
      • [1.2.2 属性](#1.2.2 属性)
      • [1.2.3 方法](#1.2.3 方法)
  • 二、🍀打造宇宙星云背景
    • [1. ☘️实现思路](#1. ☘️实现思路)
    • [2. ☘️代码样例](#2. ☘️代码样例)

一、🍀前言

本文详细介绍如何基于threejs在三维场景中打造宇宙星云背景,亲测可用。希望能帮助到您。一起学习,加油!加油!

1.1 ☘️THREE.ShaderMaterial

THREE.ShaderMaterial使用自定义shader渲染的材质。 shader是一个用GLSL编写的小程序 ,在GPU上运行。

1.1.1 ☘️注意事项

  • ShaderMaterial 只有使用 WebGLRenderer 才可以绘制正常, 因为 vertexShader 和
    fragmentShader 属性中GLSL代码必须使用WebGL来编译并运行在GPU中。
  • 从 THREE r72开始,不再支持在ShaderMaterial中直接分配属性。 必须使用
    BufferGeometry实例,使用BufferAttribute实例来定义自定义属性。
  • 从 THREE r77开始,WebGLRenderTarget 或 WebGLCubeRenderTarget
    实例不再被用作uniforms。 必须使用它们的texture 属性。
  • 内置attributes和uniforms与代码一起传递到shaders。
    如果您不希望WebGLProgram向shader代码添加任何内容,则可以使用RawShaderMaterial而不是此类。
  • 您可以使用指令#pragma unroll_loop_start,#pragma unroll_loop_end
    以便通过shader预处理器在GLSL中展开for循环。 该指令必须放在循环的正上方。循环格式必须与定义的标准相对应。
  • 循环必须标准化normalized。
  • 循环变量必须是i。
  • 对于给定的迭代,值 UNROLLED_LOOP_INDEX 将替换为 i 的显式值,并且可以在预处理器语句中使用。
javascript 复制代码
#pragma unroll_loop_start
for ( int i = 0; i < 10; i ++ ) {

	// ...

}
#pragma unroll_loop_end

代码示例

javascript 复制代码
const material = new THREE.ShaderMaterial( {
	uniforms: {
		time: { value: 1.0 },
		resolution: { value: new THREE.Vector2() }
	},
	vertexShader: document.getElementById( 'vertexShader' ).textContent,
	fragmentShader: document.getElementById( 'fragmentShader' ).textContent
} );

1.1.2 ☘️构造函数

ShaderMaterial( parameters : Object )

parameters - (可选)用于定义材质外观的对象,具有一个或多个属性。 材质的任何属性都可以从此处传入(包括从Material继承的任何属性)。

1.1.3 ☘️属性

共有属性请参见其基类Material

.clipping : Boolean

定义此材质是否支持剪裁; 如果渲染器传递clippingPlanes uniform,则为true。默认值为false。

.defaultAttributeValues : Object

当渲染的几何体不包含这些属性但材质包含这些属性时,这些默认值将传递给shaders。这可以避免在缓冲区数据丢失时出错。

javascript 复制代码
this.defaultAttributeValues = {
	'color': [ 1, 1, 1 ],
	'uv': [ 0, 0 ],
	'uv2': [ 0, 0 ]
};

.defines : Object

使用 #define 指令在GLSL代码为顶点着色器和片段着色器定义自定义常量;每个键/值对产生一行定义语句:

javascript 复制代码
defines: {
	FOO: 15,
	BAR: true
}

这将在GLSL代码中产生如下定义语句:

javascript 复制代码
#define FOO 15
#define BAR true

.extensions : Object

一个有如下属性的对象:

javascript 复制代码
this.extensions = {
	derivatives: false, // set to use derivatives
	fragDepth: false, // set to use fragment depth values
	drawBuffers: false, // set to use draw buffers
	shaderTextureLOD: false // set to use shader texture LOD
};

.fog : Boolean

定义材质颜色是否受全局雾设置的影响; 如果将fog uniforms传递给shader,则为true。默认值为false。

.fragmentShader : String

片元着色器的GLSL代码。这是shader程序的实际代码。在上面的例子中, vertexShader 和 fragmentShader 代码是从DOM(HTML文档)中获取的; 它也可以作为一个字符串直接传递或者通过AJAX加载。

.glslVersion : String

定义自定义着色器代码的 GLSL 版本。仅与 WebGL 2 相关,以便定义是否指定 GLSL 3.0。有效值为 THREE.GLSL1 或 THREE.GLSL3。默认为空。

.index0AttributeName : String

如果设置,则调用gl.bindAttribLocation 将通用顶点索引绑定到属性变量。默认值未定义。

.isShaderMaterial : Boolean

只读标志,用于检查给定对象是否属于 ShaderMaterial 类型。

.lights : Boolean

材质是否受到光照的影响。默认值为 false。如果传递与光照相关的uniform数据到这个材质,则为true。默认是false。

.linewidth : Float

控制线框宽度。默认值为1。

由于OpenGL Core Profile与大多数平台上WebGL渲染器的限制,无论如何设置该值,线宽始终为1。

.flatShading : Boolean

定义材质是否使用平面着色进行渲染。默认值为false。

.uniforms : Object

如下形式的对象:

javascript 复制代码
{ "uniform1": { value: 1.0 }, "uniform2": { value: 2 } }

指定要传递给shader代码的uniforms;键为uniform的名称,值(value)是如下形式:

javascript 复制代码
{ value: 1.0 }

这里 value 是uniform的值。名称必须匹配 uniform 的name,和GLSL代码中的定义一样。 注意,uniforms逐帧被刷新,所以更新uniform值将立即更新GLSL代码中的相应值。

.uniformsNeedUpdate : Boolean

可用于在 Object3D.onBeforeRender() 中更改制服时强制进行制服更新。默认为假。

.vertexColors : Boolean

定义是否使用顶点着色。默认为假。

.vertexShader : String

顶点着色器的GLSL代码。这是shader程序的实际代码。 在上面的例子中,vertexShader 和 fragmentShader 代码是从DOM(HTML文档)中获取的; 它也可以作为一个字符串直接传递或者通过AJAX加载。

.wireframe : Boolean

将几何体渲染为线框(通过GL_LINES而不是GL_TRIANGLES)。默认值为false(即渲染为平面多边形)。

.wireframeLinewidth : Float

控制线框宽度。默认值为1。

由于OpenGL Core Profile与大多数平台上WebGL渲染器的限制,无论如何设置该值,线宽始终为1。

1.1.4 ☘️方法

共有方法请参见其基类Material

.clone () : ShaderMaterial this : ShaderMaterial

创建该材质的一个浅拷贝。需要注意的是,vertexShader和fragmentShader使用引用拷贝; attributes的定义也是如此; 这意味着,克隆的材质将共享相同的编译WebGLProgram; 但是,uniforms 是 值拷贝,这样对不同的材质我们可以有不同的uniforms变量。

1.2 ☘️BufferGeometry缓冲几何体

BufferGeometry是面片、线或点几何体的有效表述。包括顶点位置,面片索引、法相量、颜色值、UV 坐标和自定义缓存属性值。使用 BufferGeometry 可以有效减少向 GPU 传输上述数据所需的开销。

代码示例:

javascript 复制代码
const geometry = new THREE.BufferGeometry();
// 创建一个简单的矩形. 在这里我们左上和右下顶点被复制了两次。
// 因为在两个三角面片里,这两个顶点都需要被用到。
const vertices = new Float32Array( [
	-1.0, -1.0,  1.0,
	 1.0, -1.0,  1.0,
	 1.0,  1.0,  1.0,

	 1.0,  1.0,  1.0,
	-1.0,  1.0,  1.0,
	-1.0, -1.0,  1.0
] );

// itemSize = 3 因为每个顶点都是一个三元组。
geometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) );
const material = new THREE.MeshBasicMaterial( { color: 0xff0000 } );
const mesh = new THREE.Mesh( geometry, material );

1.2.1 构造函数

BufferGeometry()

创建一个新的 BufferGeometry. 同时将预置属性设置为默认值.

1.2.2 属性

.attributes : Object

通过 hashmap 存储该几何体相关的属性,hashmap 的 id 是当前 attribute 的名称,值是相应的 buffer。 你可以通过 .setAttribute 和 .getAttribute 添加和访问与当前几何体有关的 attribute。

.boundingBox : Box3

当前 bufferGeometry 的外边界矩形。可以通过 .computeBoundingBox() 计算。默认值是 null。

.boundingSphere : Sphere

当前 bufferGeometry 的外边界球形。可以通过 .computeBoundingSphere() 计算。默认值是 null。

.drawRange : Object

用于判断几何体的哪个部分需要被渲染。该值不应该直接被设置,而需要通过 .setDrawRange 进行设置。默认值为

javascript 复制代码
{ start: 0, count: Infinity }

.groups : Array

将当前几何体分割成组进行渲染,每个部分都会在单独的 WebGL 的 draw call 中进行绘制。该方法可以让当前的 bufferGeometry 可以使用一个材质队列进行描述。分割后的每个部分都是一个如下的表单:

javascript 复制代码
{ start: Integer, count: Integer, materialIndex: Integer }

start 表明当前 draw call 中的没有索引的几何体的几何体的第一个顶点;或者第一个三角面片的索引。 count 指明当前分割包含多少顶点(或 indices)。 materialIndex 指出当前用到的材质队列的索引。通过 .addGroup 来增加组,而不是直接更改当前队列。

.id : Integer

当前 bufferGeometry 的唯一编号。

.index : BufferAttribute

允许顶点在多个三角面片间可以重用。这样的顶点被称为"已索引的三角面片(indexed triangles)。 每个三角面片都和三个顶点的索引相关。该 attribute 因此所存储的是每个三角面片的三个顶点的索引。 如果该 attribute 没有设置过,则 renderer 假设每三个连续的位置代表一个三角面片。 默认值是 null。

.isBufferGeometry : Boolean

只读标志,用于检查给定对象是否属于 BufferGeometry 类型。

.morphAttributes : Object

存储 BufferAttribute 的 Hashmap,存储了几何体 morph targets 的细节信息。注意:渲染几何体后,变形属性数据无法更改。您将必须调用 .dispose(),并创建 BufferGeometry 的新实例。

.morphTargetsRelative : Boolean

用于控制变形目标行为;当设置为 true 时,变形目标数据被视为相对偏移,而不是绝对位置/法线。默认为假。

.name : String

当前 bufferGeometry 实例的可选别名。默认值是空字符串。

.userData : Object

存储 BufferGeometry 的自定义数据的对象。为保持对象在克隆时完整,该对象不应该包括任何函数的引用。

.uuid : String

当前对象实例的 UUID,该值会自动被分配,且不应被修改。

1.2.3 方法

.setAttribute ( name : String, attribute : BufferAttribute ) : this

为当前几何体设置一个 attribute 属性。在类的内部,有一个存储 .attributes 的 hashmap, 通过该 hashmap,遍历 attributes 的速度会更快。而使用该方法,可以向 hashmap 内部增加 attribute。 所以,你需要使用该方法来添加 attributes。

.addGroup ( start : Integer, count : Integer, materialIndex : Integer ) : undefined

为当前几何体增加一个 group,详见 groups 属性。

.applyMatrix4 ( matrix : Matrix4 ) : this

用给定矩阵转换几何体的顶点坐标。

.center () : this

根据边界矩形将几何体居中。

.clone () : BufferGeometry

克隆当前的 BufferGeometry。

.copy ( bufferGeometry : BufferGeometry ) : this

将参数指定的 BufferGeometry 的值拷贝到当前 BufferGeometry 中。

.clearGroups ( ) : undefined

清空所有的 groups。

.computeBoundingBox () : undefined

计算当前几何体的的边界矩形,该操作会更新已有 [param:.boundingBox]。边界矩形不会默认计算,需要调用该接口指定计算边界矩形,否则保持默认值 null。

.computeBoundingSphere () : undefined

计算当前几何体的的边界球形,该操作会更新已有 [param:.boundingSphere]。边界球形不会默认计算,需要调用该接口指定计算边界球形,否则保持默认值 null。

.computeTangents () : undefined

计算切线属性并将其添加到此几何体。仅索引几何体支持计算,并且定义了位置、法线和 uv 属性。使用切线空间法线贴图时,请改用 BufferGeometryUtils.computeMikkTSpaceTangents 提供的 MikkTSpace 算法。

.computeVertexNormals () : undefined

通过面片法向量的平均值计算每个顶点的法向量。

.dispose () : undefined

从内存中销毁对象。如果在运行是需要从内存中删除 BufferGeometry,则需要调用该函数。

.getAttribute ( name : String ) : BufferAttribute

返回指定名称的 attribute。

.getIndex () : BufferAttribute

返回缓存相关的 .index。

.hasAttribute ( name : String ) : Boolean

如果具有指定名称的属性存在,则返回 true。

.lookAt ( vector : Vector3 ) : this

vector - 几何体所朝向的世界坐标。旋转几何体朝向控件中的一点。该过程通常在一次处理中完成,不会循环处理。典型的用法是过通过调用 Object3D.lookAt 实时改变 mesh 朝向。

.normalizeNormals () : undefined

几何体中的每个法向量长度将会为 1。这样操作会更正光线在表面的效果。

.deleteAttribute ( name : String ) : BufferAttribute

删除具有指定名称的 attribute。

.rotateX ( radians : Float ) : this

在 X 轴上旋转几何体。该操作一般在一次处理中完成,不会循环处理。典型的用法是通过调用 Object3D.rotation 实时旋转几何体。

.rotateY ( radians : Float ) : this

在 Y 轴上旋转几何体。该操作一般在一次处理中完成,不会循环处理。典型的用法是通过调用 Object3D.rotation 实时旋转几何体。

.rotateZ ( radians : Float ) : this

在 Z 轴上旋转几何体。该操作一般在一次处理中完成,不会循环处理。典型的用法是通过调用 Object3D.rotation 实时旋转几何体。

.scale ( x : Float, y : Float, z : Float ) : this

缩放几何体。该操作一般在一次处理中完成,不会循环处理。典型的用法是通过调用 Object3D.scale 实时旋转几何体。

.setIndex ( index : BufferAttribute ) : this

设置缓存的 .index。

.setDrawRange ( start : Integer, count : Integer ) : undefined

设置缓存的 .drawRange。详见相关属性说明。

.setFromPoints ( points : Array ) : this

通过点队列设置该 BufferGeometry 的 attribute。

.toJSON () : Object

返回代表该 BufferGeometry 的 JSON 对象。

.toNonIndexed () : BufferGeometry

返回已索引的 BufferGeometry 的非索引版本。

.translate ( x : Float, y : Float, z : Float ) : this

移动几何体。该操作一般在一次处理中完成,不会循环处理。典型的用法是通过调用 Object3D.rotation 实时旋转几何体。

二、🍀打造宇宙星云背景

1. ☘️实现思路

借助BufferGeometry、ShaderMaterial等打造星空背景,借助BufferGeometry、Points、PointsMaterial等打造宇宙星云,共同组成宇宙星云背景。

2. ☘️代码样例

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>宇宙星云背景</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            overflow: hidden;
            background: #000;
            font-family: "Arial", sans-serif;
        }
        #canvas-container {
            width: 100vw;
            height: 100vh;
            position: relative;
        }
        .controls {
            position: absolute;
            top: 20px;
            left: 20px;
            z-index: 100;
            background: rgba(0, 0, 0, 0.7);
            padding: 20px;
            border-radius: 10px;
            color: white;
            backdrop-filter: blur(10px);
            border: 1px solid rgba(255, 255, 255, 0.1);
        }
        .controls h3 {
            margin-bottom: 15px;
            color: #00ffff;
            font-size: 16px;
        }
        .control-group {
            margin-bottom: 15px;
        }
        .control-group label {
            display: block;
            margin-bottom: 5px;
            font-size: 12px;
            color: #ccc;
        }
        .control-group input[type="range"] {
            width: 150px;
            height: 5px;
            background: #333;
            outline: none;
            border-radius: 5px;
        }
        .control-group input[type="range"]::-webkit-slider-thumb {
            appearance: none;
            width: 15px;
            height: 15px;
            background: #00ffff;
            border-radius: 50%;
            cursor: pointer;
        }
        .control-group button {
            background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
            border: none;
            color: white;
            padding: 8px 15px;
            border-radius: 5px;
            cursor: pointer;
            font-size: 12px;
            margin-right: 10px;
            transition: transform 0.2s;
        }
        .control-group button:hover {
            transform: scale(1.05);
        }
        .info {
            position: absolute;
            bottom: 20px;
            right: 20px;
            color: rgba(255, 255, 255, 0.7);
            font-size: 12px;
            text-align: right;
        }
        .loading {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: white;
            font-size: 18px;
            z-index: 200;
        }
    </style>
</head>
<body>
<div id="canvas-container">
    <div class="loading" id="loading">正在加载宇宙星云...</div>
    <div class="controls">
        <h3>🌌 星云控制台</h3>
        <div class="control-group">
            <label>粒子数量</label>
            <input type="range" id="particleCount" min="1000" max="20000" value="8000" />
        </div>
        <div class="control-group">
            <label>旋转速度</label>
            <input type="range" id="rotationSpeed" min="0" max="0.02" step="0.001" value="0.005" />
        </div>
        <div class="control-group">
            <label>星云密度</label>
            <input type="range" id="nebulaDensity" min="0.1" max="2" step="0.1" value="1" />
        </div>
        <div class="control-group">
            <button id="colorChange">变换颜色</button>
            <button id="resetView">重置视角</button>
        </div>
    </div>
    <div class="info">
        <div>🖱️ 鼠标拖拽旋转视角</div>
        <div>🔍 滚轮缩放</div>
        <div>⚡ 实时渲染中</div>
    </div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script>
  class NebulaBackground {
    constructor() {
      this.scene = null;
      this.camera = null;
      this.renderer = null;
      this.controls = null;
      this.nebula = null;
      this.stars = null;
      this.time = 0;
      this.colorScheme = 0;
      this.init();
      this.createNebula();
      this.createStars();
      this.setupControls();
      this.animate();
      // 隐藏加载提示
      document.getElementById("loading").style.display = "none";
    }
    init() {
      // 创建场景
      this.scene = new THREE.Scene();
      // 创建相机
      this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
      this.camera.position.set(0, 0, 50);
      // 创建渲染器
      this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
      this.renderer.setSize(window.innerWidth, window.innerHeight);
      this.renderer.setClearColor(0x000000, 1);
      document.getElementById("canvas-container").appendChild(this.renderer.domElement);
      // 添加轨道控制
      this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
      this.controls.enableDamping = true;
      this.controls.dampingFactor = 0.05;
      this.controls.enableZoom = true;
      this.controls.autoRotate = false;
      // 窗口大小调整
      window.addEventListener("resize", () => this.onWindowResize());
    }
    createNebula() {
      const particleCount = parseInt(document.getElementById("particleCount").value);
      // 创建粒子几何体
      const geometry = new THREE.BufferGeometry();
      const positions = new Float32Array(particleCount * 3);
      const colors = new Float32Array(particleCount * 3);
      const sizes = new Float32Array(particleCount);
      // 颜色方案
      const colorSchemes = [
        { r: 0.4, g: 0.2, b: 0.8 }, // 紫色
        { r: 0.8, g: 0.2, b: 0.4 }, // 红色
        { r: 0.2, g: 0.8, b: 0.4 }, // 绿色
        { r: 0.2, g: 0.4, b: 0.8 }, // 蓝色
      ];
      const currentScheme = colorSchemes[this.colorScheme];
      for (let i = 0; i < particleCount; i++) {
        // 创建星云形状 - 使用噪声函数
        const i3 = i * 3;
        const radius = Math.random() * 30 + 10;
        const theta = Math.random() * Math.PI * 2;
        const phi = Math.random() * Math.PI;
        // 添加一些噪声使星云看起来更自然
        const noise = (Math.sin(i * 0.1) + Math.cos(i * 0.05)) * 5;
        positions[i3] = (radius + noise) * Math.sin(phi) * Math.cos(theta);
        positions[i3 + 1] = (radius + noise) * Math.sin(phi) * Math.sin(theta);
        positions[i3 + 2] = (radius + noise) * Math.cos(phi);
        // 颜色变化
        const colorVariation = Math.random() * 0.5;
        colors[i3] = currentScheme.r + colorVariation;
        colors[i3 + 1] = currentScheme.g + colorVariation * 0.5;
        colors[i3 + 2] = currentScheme.b + colorVariation;
        // 粒子大小
        sizes[i] = Math.random() * 3 + 1;
      }
      geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
      geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
      geometry.setAttribute("size", new THREE.BufferAttribute(sizes, 1));
      // 创建着色器材质
      const material = new THREE.ShaderMaterial({
        uniforms: {
          time: { value: 0 },
          density: { value: parseFloat(document.getElementById("nebulaDensity").value) },
        },
        vertexShader: `
                        attribute float size;
                        attribute vec3 color;
                        varying vec3 vColor;
                        uniform float time;

                        void main() {
                            vColor = color;
                            vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
                            gl_PointSize = size * (300.0 / -mvPosition.z);
                            gl_Position = projectionMatrix * mvPosition;
                        }
                    `,
        fragmentShader: `
                        varying vec3 vColor;
                        uniform float density;

                        void main() {
                            vec2 center = gl_PointCoord - vec2(0.5);
                            float distance = length(center);

                            if (distance > 0.5) {
                                discard;
                            }

                            float alpha = 1.0 - smoothstep(0.0, 0.5, distance);
                            alpha *= density;

                            gl_FragColor = vec4(vColor, alpha);
                        }
                    `,
        transparent: true,
        blending: THREE.AdditiveBlending,
        depthWrite: false,
      });
      // 移除旧的星云
      if (this.nebula) {
        this.scene.remove(this.nebula);
      }
      this.nebula = new THREE.Points(geometry, material);
      this.scene.add(this.nebula);
    }
    createStars() {
      const starCount = 2000;
      const geometry = new THREE.BufferGeometry();
      const positions = new Float32Array(starCount * 3);
      for (let i = 0; i < starCount; i++) {
        const i3 = i * 3;
        const radius = Math.random() * 200 + 100;
        const theta = Math.random() * Math.PI * 2;
        const phi = Math.random() * Math.PI;
        positions[i3] = radius * Math.sin(phi) * Math.cos(theta);
        positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
        positions[i3 + 2] = radius * Math.cos(phi);
      }
      geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
      const material = new THREE.PointsMaterial({
        color: 0xffffff,
        size: 0.5,
        transparent: true,
        opacity: 0.8,
      });
      if (this.stars) {
        this.scene.remove(this.stars);
      }
      this.stars = new THREE.Points(geometry, material);
      this.scene.add(this.stars);
    }
    setupControls() {
      // 粒子数量控制
      document.getElementById("particleCount").addEventListener("input", (e) => {
        this.createNebula();
      });
      // 旋转速度控制
      document.getElementById("rotationSpeed").addEventListener("input", (e) => {
        this.controls.autoRotateSpeed = parseFloat(e.target.value) * 100;
      });
      // 星云密度控制
      document.getElementById("nebulaDensity").addEventListener("input", (e) => {
        if (this.nebula) {
          this.nebula.material.uniforms.density.value = parseFloat(e.target.value);
        }
      });
      // 颜色变换
      document.getElementById("colorChange").addEventListener("click", () => {
        this.colorScheme = (this.colorScheme + 1) % 4;
        this.createNebula();
      });
      // 重置视角
      document.getElementById("resetView").addEventListener("click", () => {
        this.camera.position.set(0, 0, 50);
        this.controls.reset();
      });
    }
    animate() {
      requestAnimationFrame(() => this.animate());
      this.time += 0.01;
      // 更新控制器
      this.controls.update();
      // 旋转星云
      if (this.nebula) {
        this.nebula.rotation.y += parseFloat(document.getElementById("rotationSpeed").value);
        this.nebula.rotation.x += parseFloat(document.getElementById("rotationSpeed").value) * 0.5;
        this.nebula.material.uniforms.time.value = this.time;
      }
      // 轻微旋转星星
      if (this.stars) {
        this.stars.rotation.y += 0.0002;
      }
      // 渲染场景
      this.renderer.render(this.scene, this.camera);
    }
    onWindowResize() {
      this.camera.aspect = window.innerWidth / window.innerHeight;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(window.innerWidth, window.innerHeight);
    }
  }
  // 启动应用
  window.addEventListener("load", () => {
    new NebulaBackground();
  });
</script>
</body>
</html>

效果如下: