开发一个基于 WebGL 的分形几何探索
2026-01-08
开发一个基于 WebGL 的分形几何探索器,允许用户实时渲染和探索曼德博集合(Mandelbrot Set)和朱利亚集合(Julia Set)。用户应能通过鼠标滚轮无缝缩放,观察无限细节,并支持拖拽平移。侧边栏提供数学参数控制:迭代次数、色彩映射方案(如彩虹色、热力图、黑白高对比),以及 Julia 集的复数常数 C 的实时调节滑块。请确保使用 GLSL 着色器进行 GPU 加速,以保证在高分辨率下的流畅体验。UI 风格需体现数学的神秘与精确,采用半透明暗黑玻璃风格。
思考过程
**Conceptualizing the Fractal Explorer**
I've been focusing on the core technologies required for this project. Specifically, the need for WebGL to leverage GPU power to generate the fractals. I'm moving toward the GLSL shader code for rendering and thinking about how to handle user input for zooming and panning.
**Refining the Shader Logic**
I'm now zeroing in on the shader implementation. The core will involve transforming screen coordinates to complex numbers and iterating the Mandelbrot formula within the fragment shader. I'm considering using uniform variables to expose iteration count, color scheme selection, and the Julia constant (for the Julia set) to the UI. Performance optimization is a priority, so I'll strive for tight, efficient shader code.
**Decomposing the Project**
Now, I'm diving deeper into the required steps. I'm focusing on breaking down the project into logical parts: HTML structure, CSS styling, Three.js setup, vertex/fragment shader, and interaction logic. The detailed plan helps ensure a structured approach to tackle each component methodically. I am beginning to visualize the UI layout, too, including the sidebar and the canvas. I'm going to start by getting the basic rendering of the Mandelbrot set up before I move on to the user controls.
**Mapping Out the Code**
I'm now fully immersed in the code planning phase. Three.js is confirmed for its convenience, and I've sketched the structure: a full-screen quad with a custom shader material. My focus is on the fragment shader's core: the complex number calculations. I've mapped out the zoom logic and the need for uniforms, along with the color palette strategy. I'm focusing on the UI design next.
**Formulating the Implementation**
I've now laid out the complete plan. I'll utilize Three.js for boilerplate handling and then focus on the core: a custom fragment shader to execute the Mandelbrot formula, incorporating user-adjustable parameters like zoom, pan, and iteration count. The plan includes HTML structure, styling for the "dark glass" UI, and JavaScript for the UI and the control logic. The shader will need coordinate mapping, Mandelbrot/Julia logic, and color scheme implementation.
**Synthesizing the Implementation**
I'm now consolidating the plan into a functional structure, focusing on a single HTML file with embedded styles and JavaScript. Three.js will be used for setting up the scene and handling interactions. My final focus is on the fragment shader, which includes the Mandelbrot/Julia formulas, UI integration, and smooth color scheme implementation. I'm beginning the practical code structure.
这是一个完整的单文件解决方案,包含 HTML、CSS(暗黑玻璃风格 UI)和基于 Three.js 的 WebGL/GLSL 核心逻辑。
你可以直接将以下代码保存为 `.html` 文件,然后在浏览器中打开。
### 特性说明:
1. **高性能渲染**:核心算法完全在 GPU 片段着色器(Fragment Shader)中运行。
2. **无限细节**:虽然受限于 `highp float` 精度(大约放大到 $10^{7}$ 倍),但足以展示惊人的细节。
3. **平滑着色**:使用了重整化平滑算法,消除了传统迭代图中的色带锯齿。
4. **交互设计**:
* **滚轮**:以鼠标为中心缩放。
* **拖拽**:平移视图。
* **侧边栏**:全参数控制(模式切换、迭代次数、Julia 常数、配色方案)。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGL 分形几何探索器</title>
<style>
:root {
--glass-bg: rgba(18, 18, 24, 0.75);
--glass-border: rgba(255, 255, 255, 0.1);
--accent: #00d2ff;
--text-main: #e0e0e0;
--text-dim: #888;
}
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #000;
font-family: 'Courier New', Courier, monospace; /* 数学风格字体 */
color: var(--text-main);
}
#canvas-container {
width: 100%;
height: 100%;
display: block;
}
/* 侧边栏 UI - 暗黑玻璃风格 */
#ui-panel {
position: absolute;
top: 20px;
right: 20px;
width: 320px;
padding: 25px;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
transition: opacity 0.3s;
user-select: none;
}
h1 {
font-size: 18px;
margin: 0 0 15px 0;
border-bottom: 1px solid var(--glass-border);
padding-bottom: 10px;
color: var(--accent);
letter-spacing: 1px;
text-transform: uppercase;
}
.control-group {
margin-bottom: 18px;
}
label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-dim);
margin-bottom: 5px;
}
.value-display {
color: var(--accent);
}
/* 自定义滑块 */
input[type="range"] {
-webkit-appearance: none;
width: 100%;
background: transparent;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 14px;
width: 14px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
margin-top: -6px;
box-shadow: 0 0 10px var(--accent);
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 2px;
background: var(--glass-border);
}
/* 自定义下拉框 */
select {
width: 100%;
background: rgba(0,0,0,0.3);
border: 1px solid var(--glass-border);
color: var(--text-main);
padding: 8px;
border-radius: 4px;
font-family: inherit;
outline: none;
}
/* 切换按钮 */
.mode-switch {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.mode-btn {
flex: 1;
padding: 8px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--glass-border);
color: var(--text-dim);
cursor: pointer;
text-align: center;
font-size: 12px;
border-radius: 4px;
transition: all 0.2s;
}
.mode-btn.active {
background: rgba(0, 210, 255, 0.15);
border-color: var(--accent);
color: var(--accent);
}
#julia-controls {
display: none; /* 默认隐藏,仅在 Julia 模式显示 */
border-top: 1px solid var(--glass-border);
padding-top: 15px;
margin-top: 15px;
}
.info {
font-size: 10px;
color: var(--text-dim);
margin-top: 20px;
text-align: center;
opacity: 0.6;
}
</style>
</head>
<body>
<div id="canvas-container"></div>
<div id="ui-panel">
<h1>Fractal Geometry</h1>
<div class="mode-switch">
<div class="mode-btn active" id="btn-mandelbrot">Mandelbrot</div>
<div class="mode-btn" id="btn-julia">Julia Set</div>
</div>
<div class="control-group">
<label>Iterations <span class="value-display" id="val-iter">300</span></label>
<input type="range" id="input-iter" min="50" max="1000" value="300">
</div>
<div class="control-group">
<label>Color Scheme</label>
<select id="input-color">
<option value="0">Classic Electric (经典电子)</option>
<option value="1">Fire & Ice (冰火)</option>
<option value="2">Monochrome (黑白高对比)</option>
<option value="3">Psychedelic (迷幻)</option>
</select>
</div>
<div id="julia-controls">
<label style="color: var(--accent);">Julia Constant C</label>
<div class="control-group">
<label>Real (Cx) <span class="value-display" id="val-cx">-0.7</span></label>
<input type="range" id="input-cx" min="-2.0" max="2.0" step="0.001" value="-0.7">
</div>
<div class="control-group">
<label>Imaginary (Cy) <span class="value-display" id="val-cy">0.27</span></label>
<input type="range" id="input-cy" min="-2.0" max="2.0" step="0.001" value="0.270">
</div>
</div>
<div class="info">
Scroll to Zoom • Drag to Pan<br>
GPU Accelerated Rendering
</div>
</div>
<!-- 引入 Three.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- Shader 代码 -->
<script id="vertexShader" type="x-shader/x-vertex">
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
</script>
<script id="fragmentShader" type="x-shader/x-fragment">
precision highp float;
uniform vec2 u_resolution;
uniform vec2 u_pan;
uniform float u_zoom;
uniform int u_iterations;
uniform bool u_isJulia;
uniform vec2 u_juliaC;
uniform int u_colorScheme;
// 简单的复数乘法
vec2 complexSquare(vec2 z) {
return vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);
}
// 调色板函数 (Iquilez 方法)
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
// 1. 坐标映射:将屏幕像素映射到复平面
vec2 st = (gl_FragCoord.xy - u_resolution.xy * 0.5) / u_resolution.y;
// 应用缩放和平移
vec2 c;
vec2 z;
vec2 uvMapped = st / u_zoom + u_pan;
if (u_isJulia) {
z = uvMapped;
c = u_juliaC;
} else {
z = vec2(0.0);
c = uvMapped;
}
// 2. 迭代核心
float iter = 0.0;
float maxIter = float(u_iterations);
float distSq = 0.0;
for (int i = 0; i < 1000; i++) { // GLSL循环限制,使用足够大的常数
if (i >= u_iterations) break;
z = complexSquare(z) + c;
distSq = dot(z, z);
if (distSq > 4.0) break; // 逃逸条件
iter += 1.0;
}
// 3. 着色逻辑
vec3 color = vec3(0.0);
if (iter < maxIter) {
// 平滑着色算法:消除迭代间的颜色断层
float log_zn = log(distSq) / 2.0;
float nu = log(log_zn / log(2.0)) / log(2.0);
float smoothIter = iter + 1.0 - nu;
// 归一化 t
float t = smoothIter / 60.0; // 除以系数调整颜色频率
if (u_colorScheme == 0) {
// Electric
color = palette(t, vec3(0.5), vec3(0.5), vec3(1.0), vec3(0.00, 0.33, 0.67));
} else if (u_colorScheme == 1) {
// Fire & Ice
color = palette(t, vec3(0.5), vec3(0.5), vec3(1.0, 1.0, 0.5), vec3(0.8, 0.9, 0.3));
} else if (u_colorScheme == 2) {
// Monochrome
float v = fract(smoothIter * 0.1);
color = vec3(smoothstep(0.4, 0.6, v));
} else {
// Psychedelic
color = palette(t, vec3(0.5), vec3(0.5), vec3(2.0, 1.0, 0.0), vec3(0.5, 0.20, 0.25));
}
}
// 如果点在集合内 (iter == maxIter),保持黑色 (color = 0.0)
gl_FragColor = vec4(color, 1.0);
}
</script>
<script>
// --- 1. Three.js Setup ---
const container = document.getElementById('canvas-container');
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// --- 2. Shader Uniforms State ---
const state = {
pan: new THREE.Vector2(-0.5, 0.0), // Mandelbrot 初始中心
zoom: 0.8,
iterations: 300,
isJulia: false,
juliaC: new THREE.Vector2(-0.7, 0.27),
colorScheme: 0
};
const uniforms = {
u_resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
u_pan: { value: state.pan },
u_zoom: { value: state.zoom },
u_iterations: { value: state.iterations },
u_isJulia: { value: state.isJulia },
u_juliaC: { value: state.juliaC },
u_colorScheme: { value: state.colorScheme }
};
// 创建全屏平面
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader: document.getElementById('vertexShader').textContent,
fragmentShader: document.getElementById('fragmentShader').textContent,
uniforms: uniforms
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// --- 3. 交互逻辑 (核心数学部分) ---
// 鼠标拖拽平移
let isDragging = false;
let lastMouse = new THREE.Vector2();
container.addEventListener('mousedown', (e) => {
isDragging = true;
lastMouse.set(e.clientX, e.clientY);
container.style.cursor = 'grabbing';
});
window.addEventListener('mouseup', () => {
isDragging = false;
container.style.cursor = 'default';
});
container.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - lastMouse.x;
const dy = e.clientY - lastMouse.y;
lastMouse.set(e.clientX, e.clientY);
// 将屏幕像素移动转换为复平面坐标移动
// dy 前的负号是因为屏幕Y轴向下,数学Y轴向上
const pixelScale = 1.0 / (state.zoom * renderer.domElement.height);
// 修正:横纵比处理
// 着色器逻辑是: (coord - res*0.5) / res.y
// 所以 deltaX 在数学空间 = dx / res.y / zoom
const aspectCorrectedDx = dx / renderer.domElement.height / state.zoom;
const aspectCorrectedDy = -dy / renderer.domElement.height / state.zoom;
state.pan.x -= aspectCorrectedDx;
state.pan.y -= aspectCorrectedDy;
});
// 鼠标滚轮缩放
container.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = 1.1;
const direction = e.deltaY > 0 ? 1 / zoomFactor : zoomFactor;
// 为了实现"以鼠标为中心缩放",我们需要在缩放前后计算鼠标在数学空间的位置
// Mouse Screen Coords -> Normalized Device Coords (-1 to 1) -> Aspect Ratio Corrected
const rect = renderer.domElement.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// 获取鼠标当前在复平面上的位置 (World Position)
// 公式需与 Shader 保持一致: vec2 st = (gl_FragCoord.xy - u_resolution.xy * 0.5) / u_resolution.y;
// vec2 uvMapped = st / u_zoom + u_pan;
const stX = (mouseX - renderer.domElement.width * 0.5) / renderer.domElement.height;
const stY = ((renderer.domElement.height - mouseY) - renderer.domElement.height * 0.5) / renderer.domElement.height; // Flip Y
const mouseWorldX = stX / state.zoom + state.pan.x;
const mouseWorldY = stY / state.zoom + state.pan.y;
// 应用新的缩放
state.zoom *= direction;
// 计算新的 Pan,使得 mouseWorld 保持不变
// mouseWorld = st / newZoom + newPan
// newPan = mouseWorld - st / newZoom
state.pan.x = mouseWorldX - stX / state.zoom;
state.pan.y = mouseWorldY - stY / state.zoom;
uniforms.u_zoom.value = state.zoom;
}, { passive: false });
// 窗口大小调整
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
uniforms.u_resolution.value.set(window.innerWidth, window.innerHeight);
});
// --- 4. UI 逻辑绑定 ---
// 模式切换
const btnMandel = document.getElementById('btn-mandelbrot');
const btnJulia = document.getElementById('btn-julia');
const juliaControls = document.getElementById('julia-controls');
function setMode(isJulia) {
state.isJulia = isJulia;
uniforms.u_isJulia.value = isJulia;
if (isJulia) {
btnJulia.classList.add('active');
btnMandel.classList.remove('active');
juliaControls.style.display = 'block';
// Julia 初始视野一般居中
state.pan.set(0, 0);
state.zoom = 0.8;
} else {
btnMandel.classList.add('active');
btnJulia.classList.remove('active');
juliaControls.style.display = 'none';
// Mandelbrot 初始视野稍微偏左
state.pan.set(-0.5, 0.0);
state.zoom = 0.8;
}
uniforms.u_zoom.value = state.zoom;
}
btnMandel.onclick = () => setMode(false);
btnJulia.onclick = () => setMode(true);
// 参数控制
const iterInput = document.getElementById('input-iter');
const iterVal = document.getElementById('val-iter');
iterInput.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
state.iterations = val;
uniforms.u_iterations.value = val;
iterVal.innerText = val;
});
const colorInput = document.getElementById('input-color');
colorInput.addEventListener('change', (e) => {
state.colorScheme = parseInt(e.target.value);
uniforms.u_colorScheme.value = state.colorScheme;
});
const cxInput = document.getElementById('input-cx');
const cxVal = document.getElementById('val-cx');
cxInput.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
state.juliaC.x = val;
cxVal.innerText = val.toFixed(3);
});
const cyInput = document.getElementById('input-cy');
const cyVal = document.getElementById('val-cy');
cyInput.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
state.juliaC.y = val;
cyVal.innerText = val.toFixed(3);
});
// --- 5. 渲染循环 ---
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
```
### 设计与实现细节:
1. **架构**:使用了 **Three.js** 作为轻量级封装,它负责创建 WebGL 上下文和绘制全屏四边形,使我们可以专注于编写 GLSL Shader 和处理数学交互。
2. **Shader (GLSL)**:
* **坐标系**:修正了宽高比(Aspect Ratio),确保在不同屏幕尺寸下分形不会变形。
* **数学核心**:实现了 $z_{n+1} = z_n^2 + c$ 的复数迭代。对于 Mandelbrot 集,`c` 是像素坐标,`z` 从 0 开始;对于 Julia 集,`z` 是像素坐标,`c` 是用户定义的常数。
* **平滑着色**:使用了 $\nu = \log_2(\log_2(|z|))$ 对迭代次数进行浮点数插值,避免了低迭代次数时的色彩断层(Banding)。
* **调色板**:实现了 Inigo Quilez 的余弦调色板算法(Cosine Palette),这种方法计算极快且颜色过渡平滑。
3. **交互逻辑 (JavaScript)**:
* **平移**:将鼠标像素位移量除以当前的 `zoom` 层级,映射到数学坐标系中。
* **缩放**:实现了类似 Google Maps 的“以鼠标指针为中心缩放”。这需要计算缩放前鼠标在数学世界的坐标,缩放后调整 `pan` (偏移量) 以保持该点不动。
4. **UI 风格**:
* 采用了 **Glassmorphism**(毛玻璃)风格,使用 `backdrop-filter: blur` 和半透明背景。
* 字体选用等宽字体(Monospace),突显数学的精确感。
* 强调色使用了青色(Cyan),与深色背景形成高科技对比。