🧪 Shader 进阶 - 高级模拟技术与交互系统
1. 🌟 突破Shader的限制 - 状态保存的挑战
1.1 为什么普通Shader无法模拟复杂现象?
在之前的文章中,所有效果都是无状态的 (Stateless),也就是说每一帧的计算都是完全独立的:
// 典型的无状态Shader - 每帧重新计算,没有"记忆"
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 只依赖当前输入(UV坐标和时间),没有"历史记忆"
float n = noise(uv + u_time);
gl_FragColor = vec4(vec3(n), 1.0);
}💡 类比理解:这就像一个失忆症患者,每秒钟都忘记之前发生的事情。他可以根据当前看到的东西做出反应,但无法根据历史信息做出决策。
1.2 无状态Shader的局限性
问题:每一帧完全独立,无法实现需要"记忆"的效果:
- ❌ 物理模拟(如弹跳的球):需要记住上一帧的位置和速度,才能计算下一帧的位置
- ❌ 细胞自动机(如生命游戏):细胞的生死取决于上一代邻居的状态
- ❌ 流体模拟(如水波):每个点的运动受到周围点历史状态的影响
💡 深层原因:GPU的Shader是并行计算的,每个像素的计算都是独立的。函数执行完毕后,局部变量就销毁了,下一帧无法访问上一帧的结果。
1.3 解决方案:用纹理充当"记忆库"
核心思路:乒乓缓冲 (Ping-Pong Buffer) - 用纹理存储状态!
既然Shader内部无法保存状态,我们就把状态保存在纹理中:
- 📖 每帧从纹理中读取上一帧的状态
- 🧮 Shader计算新的状态
- ✍️ 把结果写入另一个纹理
- 🔄 下一帧交换两个纹理的角色
2. 🏓 乒乓缓冲 (Ping-Pong Buffer) - GPU的记忆系统
2.1 🎯 核心思想:两个纹理来回切换
用两个纹理轮流充当"读取源"和"写入目标",就像乒乓球在两个球拍之间来回弹跳:
帧 N: 帧 N+1:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Texture │ → │ Shader │ │ Texture │ ← │ Shader │
│ A │ │ │ │ B │ │ │
│ (读取) │ │ 计算 │ │ (写入) │ │ 计算 │
└─────────┘ └────┬────┘ └─────────┘ └────┬────┘
写入 ↓ 读取 ↑
┌─────────┐ ┌─────────┐
│ Texture │ │ Texture │
│ B │ │ A │
│ (写入) │ │ (读取) │
└─────────┘ └─────────┘
交换角色 ↔️ 下一帧💡 类比理解:这就像两个笔记本交替使用:
- 奇数页(Texture A):先在第1页写作业,第2页抄答案
- 偶数页(Texture B):第2页的答案又变成第3页的作业题
- 每次都是"读旧的,写新的",然后交换角色
2.2 工作流程详解
规则(按顺序执行):
- 🔵 读取:从 Texture A 读取上一帧的状态数据(例如:每个像素的速度、温度、颜色等)
- 🧮 计算:Shader 根据读取的数据和物理规则,计算出新的状态
- 🟢 写入:把计算结果写入 Texture B(不能写回A,因为其他像素还在读A!)
- 🔄 交换:下一帧把 A 和 B 的角色互换,B 变成新的"读取源",A 变成新的"写入目标"
⚠️ 为什么需要两个纹理?
如果只用一个纹理,会出现"读写冲突":当你在计算第100个像素时,第1个像素已经被更新了,导致读取的数据不一致。用两个纹理可以保证读取的是完整的上一帧数据。
🔧 Three.js 实现骨架
💡 前置知识:
WebGLRenderTarget是Three.js中的"离屏画布",可以把渲染结果保存到纹理中,而不是直接显示在屏幕上。
// JavaScript 端设置双缓冲系统
import * as THREE from 'three';
class PingPongSimulation {
constructor(width, height) {
// ========== 步骤1: 创建两个 RenderTarget (帧缓冲/离屏画布) ==========
const options = {
minFilter: THREE.NearestFilter, // 最近邻采样(不模糊,精确取像素值)
magFilter: THREE.NearestFilter, // 放大时也不模糊
format: THREE.RGBAFormat, // RGBA 四通道(可存4个数值)
type: THREE.FloatType // 浮点纹理(存储精确数值,不限于0-255)
};
// 这两个buffer就是"乒"和"乓"
this.bufferA = new THREE.WebGLRenderTarget(width, height, options);
this.bufferB = new THREE.WebGLRenderTarget(width, height, options);
// ========== 步骤2: 创建模拟用的Shader材质 ==========
this.simulationMaterial = new THREE.ShaderMaterial({
uniforms: {
u_buffer: { value: null }, // 上一帧状态(稍后动态赋值)
u_resolution: { value: new THREE.Vector2(width, height) },
u_dt: { value: 0.016 } // 时间步长 (1/60秒,假设60FPS)
},
fragmentShader: simulationShaderCode // 你的模拟逻辑Shader
});
// ========== 步骤3: 创建全屏四边形(用于执行Shader) ==========
// 为什么需要四边形?因为Shader需要绑定到几何体上才能运行
this.scene = new THREE.Scene();
// 正交相机(-1到1的范围,刚好覆盖全屏)
this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
// 创建一个2x2的平面(覆盖整个屏幕)
this.quad = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
this.simulationMaterial
);
this.scene.add(this.quad);
}
update(renderer) {
// ========== 乒乓缓冲的核心逻辑 ==========
// 📖 步骤1: 把 bufferA 的纹理作为输入(读取上一帧)
this.simulationMaterial.uniforms.u_buffer.value = this.bufferA.texture;
// ✍️ 步骤2: 渲染到 bufferB(写入新一帧)
renderer.setRenderTarget(this.bufferB);
renderer.render(this.scene, this.camera);
// 🔄 步骤3: 交换 A 和 B 的角色
// JavaScript的解构赋值语法,等价于:
// let temp = this.bufferA;
// this.bufferA = this.bufferB;
// this.bufferB = temp;
[this.bufferA, this.bufferB] = [this.bufferB, this.bufferA];
}
// 获取当前最新状态的纹理(用于显示到屏幕)
getCurrentTexture() {
return this.bufferA.texture; // 交换后,A总是存储最新结果
}
}
// ========== 使用示例 ==========
const simulation = new PingPongSimulation(512, 512); // 512x512分辨率
function animate() {
requestAnimationFrame(animate);
// 1. 更新模拟(执行一次物理计算)
simulation.update(renderer);
// 2. 把模拟结果显示到屏幕
displayMaterial.uniforms.u_simulation.value = simulation.getCurrentTexture();
renderer.setRenderTarget(null); // null表示渲染到屏幕
renderer.render(displayScene, camera);
}💡 关键理解:
bufferA和bufferB是两张"画布",存储的是数据(不是图像)- 每次
update()都会:读A → 计算 → 写B → 交换ABgetCurrentTexture()返回的是"最新计算结果",可以用于显示或进一步处理
3. 🎮 实战案例1: Conway生命游戏
3.1 📜 什么是生命游戏?
Conway生命游戏(Game of Life)是1970年由数学家约翰·康威发明的经典细胞自动机 (Cellular Automaton)。虽然规则极其简单,但能产生出惊人复杂的演化模式。
💡 核心理念:整个世界是一个网格,每个格子是一个"细胞",根据简单规则决定生死,模拟生命的繁衍和灭亡。
3.2 📐 游戏规则
每个格子有两种状态:活 (1) 或 死 (0)
每一代的演化只看8个邻居的状态(上下左右+4个对角):
1️⃣ 活细胞的命运(当前格子是活的):
- ✅ 2-3个活邻居 → 继续存活(环境适宜)
- ❌ <2个活邻居 → 死亡(孤独死)
- ❌ >3个活邻居 → 死亡(拥挤致死)
2️⃣ 死细胞的命运(当前格子是死的):
- ✅ 恰好3个活邻居 → 复活(繁殖)
- ❌ 其他情况 → 保持死亡
💡 记忆技巧:活细胞需要"2-3个朋友"才能活,死细胞需要"恰好3个父母"才能诞生。
3.3 📊 经典模式演示
闪烁子 (Blinker) - 最简单的周期性结构:
初始状态(第1代): 下一代(第2代): 第3代:
□ □ □ □ □ □ □ □ □ □ □ □ □ □ □
□ ■ ■ ■ □ → □ □ ■ □ □ → □ ■ ■ ■ □
□ □ □ □ □ □ □ ■ □ □ □ □ □ □ □
□ □ ■ □ □ □ □ □ □ □
横向3个活细胞 竖向3个活细胞 又变回横向💡 分析逻辑:
- 中间的细胞(第1代中央■):有2个邻居,符合"2-3存活",继续活
- 上下位置(第2代中央列):都恰好有3个邻居,符合"3复活",变活
- 左右的细胞(第1代左右■):只有2个邻居... 等等,为什么会死?因为算上自己周围只有2个活的,边缘细胞只有1个活邻居,所以死亡
3.4 🔧 GLSL 实现
💡 实现思路:每个像素代表一个格子,读取周围8个像素的值来统计邻居数量,然后应用生命游戏规则。
#ifdef GL_ES
precision highp float; // 使用高精度浮点数
#endif
uniform sampler2D u_buffer; // 上一代的状态纹理(乒乓缓冲的"读"端)
uniform vec2 u_resolution; // 画布分辨率(例如 512x512)
void main() {
// ========== 步骤1: 计算当前像素的UV坐标 ==========
vec2 uv = gl_FragCoord.xy / u_resolution; // 归一化到 [0,1]
// 计算一个像素在UV空间的大小
// 例如:512x512的纹理,一个像素 = 1/512 = 0.00195
vec2 texel = 1.0 / u_resolution;
// ========== 步骤2: 读取当前格子的状态 ==========
// .r 表示读取红色通道(我们用红色通道存储状态:0=死,1=活)
float current = texture2D(u_buffer, uv).r;
// ========== 步骤3: 统计8个邻居中活细胞的数量 ==========
float neighbors = 0.0;
// 遍历3x3的网格(包括中心点)
for(int y = -1; y <= 1; y++) { // 上、中、下三行
for(int x = -1; x <= 1; x++) { // 左、中、右三列
// 跳过中心格子(不算自己为邻居)
if(x == 0 && y == 0) continue;
// 计算邻居的UV坐标偏移
// 例如:左上角邻居 = uv + vec2(-texel.x, -texel.y)
vec2 offset = vec2(float(x), float(y)) * texel;
// 读取该邻居的状态并累加(1=活,0=死)
neighbors += texture2D(u_buffer, uv + offset).r;
}
}
// ========== 步骤4: 应用生命游戏规则 ==========
float newState = 0.0; // 默认为死
if(current > 0.5) {
// 🟢 当前是活细胞(current接近1)
// 规则:2-3个活邻居 → 存活,否则死亡
if(neighbors >= 2.0 && neighbors <= 3.0) {
newState = 1.0; // 存活
}
// 其他情况(<2 或 >3)newState保持0.0,即死亡
} else {
// ⚪ 当前是死细胞(current接近0)
// 规则:恰好3个活邻居 → 复活
// 为什么用 2.9-3.1?因为浮点数精度问题,避免 neighbors == 3.0 判断失效
if(neighbors > 2.9 && neighbors < 3.1) {
newState = 1.0; // 复活
}
// 其他情况newState保持0.0,继续死亡
}
// ========== 步骤5: 输出新状态 ==========
// 写入到帧缓冲(乒乓缓冲的"写"端)
// 把newState同时写入RGB三个通道(方便显示为灰度图)
gl_FragColor = vec4(vec3(newState), 1.0);
}💡 为什么用
> 0.5而不是== 1.0?
浮点数运算可能产生微小误差(例如 0.999999),用阈值判断更安全。> 0.5可以区分"接近0"和"接近1"的值。
3.5 🎨 初始化 & 显示
💡 为什么需要初始化Shader?
生命游戏的第一代需要一个初始状态。我们不能在主程序里手动设置每个像素,太慢了!用Shader一次性初始化整个纹理是最高效的方法。
3.5.1 初始化随机状态
// 初始化Shader(只在开始时运行一次,给bufferA和bufferB填充初始数据)
uniform vec2 u_resolution;
// 伪随机数生成器(基于UV坐标生成随机值)
float random(vec2 st) {
// 经典的GLSL随机数算法:
// 1. 用点积把2D坐标映射到1D
// 2. 用sin函数产生混乱的周期性
// 3. 乘以大数放大精度
// 4. 取小数部分得到[0,1]的随机值
return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453123);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// 🎲 随机生成初始状态(约30%的格子为活)
// step(a, b): 如果 b >= a 返回1.0,否则返回0.0
// 所以 random(uv) > 0.7 时才返回1.0,即30%概率
float state = step(0.7, random(uv));
gl_FragColor = vec4(vec3(state), 1.0);
}💡 其他初始化方式:
- 全清空:
float state = 0.0;(所有格子死亡)- 中心种子:
if(distance(uv, vec2(0.5)) < 0.1) state = 1.0;(中心一小块活)- 特定图案:加载预设的滑翔机、太空船等经典结构
3.5.2 显示Shader(美化输出)
uniform sampler2D u_simulation; // 模拟结果纹理
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// 📖 读取当前格子的状态(0或1)
float state = texture2D(u_simulation, uv).r;
// 🎨 根据状态选择颜色
vec3 aliveColor = vec3(0.0, 1.0, 0.5); // 活 = 青绿色(充满生机)
vec3 deadColor = vec3(0.05, 0.05, 0.1); // 死 = 深蓝色(深邃背景)
// mix(a, b, t): 在a和b之间线性插值,t=0返回a,t=1返回b
vec3 color = mix(deadColor, aliveColor, state);
// ✨ 添加网格线(方便看清每个格子)
// fract(x): 取x的小数部分,产生[0,1]的周期性
vec2 grid = fract(uv * u_resolution / 2.0); // 每2个像素一个格子
// 当grid.x或grid.y接近1时(格子边缘),绘制细线
float gridLine = step(0.95, max(grid.x, grid.y)) * 0.2; // 亮度0.2
color += gridLine; // 叠加到颜色上
gl_FragColor = vec4(color, 1.0);
}💡 颜色设计技巧:
- 用对比色区分活/死(例如:绿/蓝、红/黑)
- 可以根据细胞"年龄"渐变颜色(需要额外通道存储年龄)
- 添加发光效果:
color += aliveColor * 0.3;让活细胞周围泛光
3.5.3 🎯 交互功能:鼠标绘制
在模拟Shader中添加鼠标交互,让用户可以"画"出活细胞:
uniform sampler2D u_buffer;
uniform vec2 u_resolution;
uniform vec2 u_mouse; // 鼠标位置(归一化到[0,1])
uniform bool u_mouseDown; // 是否按下鼠标左键
uniform float u_brushSize; // 画笔大小(UV空间,例如0.05)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 texel = 1.0 / u_resolution;
// ========== 正常的生命游戏逻辑 ==========
float current = texture2D(u_buffer, uv).r;
float neighbors = 0.0;
// ... (统计邻居代码,见上文)
// 应用规则计算新状态
float newState = 0.0;
if(current > 0.5) {
if(neighbors >= 2.0 && neighbors <= 3.0) {
newState = 1.0;
}
} else {
if(neighbors > 2.9 && neighbors < 3.1) {
newState = 1.0;
}
}
// ========== 鼠标交互:强制覆盖计算结果 ==========
if(u_mouseDown) {
// 计算当前像素到鼠标的距离(UV空间)
float distToMouse = distance(uv, u_mouse);
// 如果在画笔范围内,强制设为活细胞
if(distToMouse < u_brushSize) {
newState = 1.0; // 覆盖之前的计算结果
}
}
gl_FragColor = vec4(vec3(newState), 1.0);
}💡 JavaScript端的配套代码:
javascript// 监听鼠标事件 canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); // 转换为归一化坐标[0,1] uniforms.u_mouse.value.x = (e.clientX - rect.left) / rect.width; uniforms.u_mouse.value.y = 1.0 - (e.clientY - rect.top) / rect.height; // Y轴翻转 }); canvas.addEventListener('mousedown', () => { uniforms.u_mouseDown.value = true; }); canvas.addEventListener('mouseup', () => { uniforms.u_mouseDown.value = false; });
💡 进阶交互:
- 右键清除:检测
e.button === 2,设newState = 0.0- 画笔羽化:用
smoothstep让边缘柔和:newState = mix(newState, 1.0, smoothstep(u_brushSize, 0.0, distToMouse));- 拖拽绘制:记录上一帧鼠标位置,插值绘制连续线段
4. 🌊 实战案例2: 水波模拟
4.1 📐 物理原理:波动方程
水波遵循经典的波动方程(Wave Equation):
∂²h/∂t² = c² × ∇²h
数学符号解释:
- ∂²h/∂t²: 高度对时间的二阶导数(加速度)
- c: 波速(常量)
- ∇²h: 拉普拉斯算子(空间二阶导数,表示"周围和中心的差异")💡 用人话理解波动方程:
- 左边(∂²h/∂t²):某点的"加速度" — 它是加速上升还是加速下降?
- 右边(∇²h):该点和周围的"高度差" — 周围比它高,它就会被"拉"起来
- 整个方程:一个点会向周围的平均高度"回弹"
4.2 🔢 离散化为差分方程
计算机无法直接处理导数,需要转换为差分形式:
简化后的更新公式:
h_new = 2 × h_current - h_previous + c²×dt² × ∇²h_current
其中:
- h_new: 下一帧的高度
- h_current: 当前帧的高度
- h_previous: 上一帧的高度
- c: 波速(控制波传播的快慢)
- dt: 时间步长(帧间隔)
- ∇²h: 拉普拉斯算子💡 为什么需要两帧历史数据?
波是有"惯性"的!一个上升的波不会立刻停止,它会继续上升一段时间。我们需要:
- h_previous:知道它之前在哪(位置)
- h_current:知道它现在在哪(位置)
- 两者相减就能得到速度,进而推算下一帧位置
4.3 🧮 拉普拉斯算子(∇²h)的计算
拉普拉斯算子衡量"一个点和周围的差异":
∇²h ≈ (上 + 下 + 左 + 右) / 4 - 中心
物理意义:
- 如果周围比中心高 → ∇²h > 0 → 中心会被"拉"起来
- 如果周围比中心低 → ∇²h < 0 → 中心会被"压"下去
- 如果周围和中心一样 → ∇²h = 0 → 保持当前运动趋势💡 类比弹簧:想象每个点用弹簧连接到4个邻居,弹簧总是试图拉平高度差。
4.4 🔧 GLSL 实现
💡 注意:水波模拟需要3个缓冲区(previous、current、next),而不是2个!
#ifdef GL_ES
precision highp float;
#endif
uniform sampler2D u_current; // 当前帧的高度 h(t)
uniform sampler2D u_previous; // 上一帧的高度 h(t-dt)
uniform vec2 u_resolution;
uniform float u_damping; // 阻尼系数(0.98-0.995,值越小衰减越快)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 texel = 1.0 / u_resolution; // 一个像素的UV大小
// ========== 步骤1: 读取历史数据 ==========
float current = texture2D(u_current, uv).r; // 当前高度
float previous = texture2D(u_previous, uv).r; // 上一帧高度
// ========== 步骤2: 计算拉普拉斯算子(∇²h) ==========
// 读取4个邻居的高度
float left = texture2D(u_current, uv + vec2(-texel.x, 0.0)).r;
float right = texture2D(u_current, uv + vec2( texel.x, 0.0)).r;
float top = texture2D(u_current, uv + vec2(0.0, texel.y)).r;
float bottom = texture2D(u_current, uv + vec2(0.0, -texel.y)).r;
// 拉普拉斯 = (4邻域平均值) - 中心值
// 这表示"周围比我高多少"
float laplacian = (left + right + top + bottom) * 0.25 - current;
// ========== 步骤3: 应用波动方程 ==========
// h_new = 2 × h_current - h_previous + c²×dt² × laplacian
float waveSpeed = 0.2; // 波速参数(c²×dt²的简化)
float newHeight = 2.0 * current - previous + laplacian * waveSpeed;
// 解释这个公式:
// - 2.0 * current: 保持当前运动趋势(惯性)
// - previous: 减去上一帧位置(相当于考虑速度)
// - laplacian * waveSpeed: 受邻居影响产生的加速度
// ========== 步骤4: 应用阻尼(能量耗散) ==========
// 真实水波会因为粘性、摩擦等原因逐渐衰减
newHeight *= u_damping; // 例如 0.99,每帧衰减1%
// ========== 步骤5: 输出结果 ==========
gl_FragColor = vec4(vec3(newHeight), 1.0);
}💡 参数调优指南:
waveSpeed(0.1-0.5):
- 太小:波传播很慢,像果冻
- 太大:波传播太快,可能不稳定(出现爆炸性增长)
- 推荐:0.15-0.25
u_damping(0.95-0.999):
- 太小(0.9):波迅速消失,像泥浆
- 太大(0.999):波永远不消失,像理想流体
- 推荐:0.98-0.995
⚠️ 稳定性问题: 如果
waveSpeed太大,模拟会爆炸(高度值趋于无穷)。这是数值不稳定的表现。解决方法:
- 降低
waveSpeed- 增加阻尼
- 限制高度范围:
newHeight = clamp(newHeight, -1.0, 1.0);
4.5 🎨 三缓冲区管理
💡 为什么是3个?
- previous:存储 h(t-1)
- current:存储 h(t)
- next:存储计算出的 h(t+1)
每次更新后,三者循环移位:previous ← current ← next
class WaterSimulation {
constructor(width, height) {
// ========== 创建三个渲染目标 ==========
const options = {
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
format: THREE.RGBAFormat,
type: THREE.FloatType // 浮点纹理(支持负值和小数)
};
this.bufferPrevious = new THREE.WebGLRenderTarget(width, height, options);
this.bufferCurrent = new THREE.WebGLRenderTarget(width, height, options);
this.bufferNext = new THREE.WebGLRenderTarget(width, height, options);
// ========== 初始化Shader材质 ==========
this.material = new THREE.ShaderMaterial({
uniforms: {
u_current: { value: null },
u_previous: { value: null },
u_resolution: { value: new THREE.Vector2(width, height) },
u_damping: { value: 0.99 }
},
fragmentShader: waterShaderCode
});
// ... 场景和相机设置(同生命游戏)
}
update(renderer) {
// ========== 步骤1: 设置Shader输入 ==========
// 读取 current 和 previous
this.material.uniforms.u_current.value = this.bufferCurrent.texture;
this.material.uniforms.u_previous.value = this.bufferPrevious.texture;
// ========== 步骤2: 计算下一帧,写入 next ==========
renderer.setRenderTarget(this.bufferNext);
renderer.render(this.scene, this.camera);
// ========== 步骤3: 循环移动缓冲区 ==========
// 原理:previous ← current ← next ← (原previous重用)
let temp = this.bufferPrevious; // 保存最旧的
this.bufferPrevious = this.bufferCurrent; // current变成新的previous
this.bufferCurrent = this.bufferNext; // next变成新的current
this.bufferNext = temp; // 重用旧的previous作为下一帧的写入目标
// 这样就不需要每帧创建新纹理,只是交换引用
}
// ========== 添加扰动(鼠标点击产生涟漪) ==========
addDrop(x, y, strength = 0.5) {
// 方法1:直接在JavaScript修改纹理(较慢)
// 方法2:用单独的Shader绘制一个高斯波峰(推荐)
this.dropMaterial.uniforms.u_center.value.set(x, y);
this.dropMaterial.uniforms.u_strength.value = strength;
this.dropMaterial.uniforms.u_baseTexture.value = this.bufferCurrent.texture;
// 渲染到current(在当前水面上叠加扰动)
renderer.setRenderTarget(this.bufferCurrent);
renderer.render(this.dropScene, this.camera);
}
// 获取当前水面纹理(用于显示)
getCurrentTexture() {
return this.bufferCurrent.texture;
}
}💡 内存管理:三个
WebGLRenderTarget只是引用,循环移位不会复制纹理数据,非常高效!
4.6 🎨 渲染水波(显示Shader)
💡 渲染原理:高度场本身是看不见的(只是数字),我们需要:
- 把高度转换为法线(表面朝向)
- 用光照模型计算颜色
uniform sampler2D u_waterHeight; // 水面高度纹理
uniform vec2 u_resolution;
uniform vec3 u_lightDir; // 光源方向(归一化)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 texel = 1.0 / u_resolution;
// ========== 步骤1: 读取高度 ==========
float height = texture2D(u_waterHeight, uv).r;
// ========== 步骤2: 计算法线(通过高度梯度) ==========
// 原理:法线垂直于表面,可以通过高度变化率计算
// X方向的高度变化率(梯度)
float dx = texture2D(u_waterHeight, uv + vec2(texel.x, 0.0)).r
- texture2D(u_waterHeight, uv - vec2(texel.x, 0.0)).r;
// Y方向的高度变化率
float dy = texture2D(u_waterHeight, uv + vec2(0.0, texel.y)).r
- texture2D(u_waterHeight, uv - vec2(0.0, texel.y)).r;
// 构建法线向量:
// - X/Y分量:高度梯度(取负,因为斜率向上时法线向下)
// - Z分量:固定为1(垂直向上的基准)
vec3 normal = normalize(vec3(-dx * 10.0, -dy * 10.0, 1.0));
// 乘以10.0是为了夸大法线效果,让水波更明显
// ========== 步骤3: 漫反射光照(Lambertian) ==========
// 漫反射:光线和法线的夹角越小,越亮
float diffuse = max(0.0, dot(normal, u_lightDir));
// ========== 步骤4: 计算颜色 ==========
vec3 waterColor = vec3(0.1, 0.3, 0.6); // 基础水色(深蓝)
vec3 color = waterColor * (0.5 + 0.5 * diffuse); // 环境光+漫反射
// ========== 步骤5: 镜面高光(Blinn-Phong) ==========
vec3 viewDir = vec3(0.0, 0.0, 1.0); // 假设相机朝向Z轴
vec3 halfDir = normalize(u_lightDir + viewDir); // 半角向量
float specular = pow(max(0.0, dot(normal, halfDir)), 32.0);
// 指数32控制高光范围(值越大,高光越聚集)
color += vec3(specular); // 叠加白色高光
// ========== 步骤6: 可选 - 根据高度调整颜色 ==========
// 高处更亮(波峰),低处更暗(波谷)
color += height * 0.2;
gl_FragColor = vec4(color, 1.0);
}💡 进阶渲染技巧:
折射效果:用法线扭曲背景图像的UV坐标
glslvec2 distortion = normal.xy * 0.1; vec3 background = texture2D(u_background, uv + distortion).rgb; color = mix(waterColor, background, 0.5);菲涅尔效果:边缘更透明,中心更反射
glslfloat fresnel = pow(1.0 - max(0.0, dot(normal, viewDir)), 3.0); color = mix(waterColor, reflectionColor, fresnel);深度颜色:根据高度映射不同颜色(浅水区 = 青色,深水区 = 深蓝)
glslvec3 shallowColor = vec3(0.3, 0.7, 0.9); vec3 deepColor = vec3(0.0, 0.1, 0.3); color = mix(deepColor, shallowColor, smoothstep(-0.5, 0.5, height));
5. 🎨 实战案例3: 反应扩散系统 (Reaction-Diffusion)
5.1 🧬 什么是反应扩散?
反应扩散系统是图灵斑图 (Turing Patterns) 的基础,用于模拟自然界的纹理(如豹纹、斑马纹、贝壳花纹)。
💡 核心思想:两种化学物质在空间中扩散(像墨水晕开),同时发生化学反应(互相转化)。在特定参数下,会自发形成稳定的花纹。
5.2 🧪 Gray-Scott 模型详解
最流行的反应扩散模型是 Gray-Scott 模型:
∂A/∂t = DA × ∇²A - A×B² + f×(1-A)
∂B/∂t = DB × ∇²B + A×B² - (k+f)×B
数学符号解释:
- A, B: 两种化学物质的浓度(0到1之间)
- DA, DB: 扩散系数(A通常扩散快,B慢)
- f: 供给速率 (feed rate) - A的补充速度
- k: 消耗速率 (kill rate) - B的衰减速度
- ∇²: 拉普拉斯算子(扩散项)5.3 📖 用故事理解公式
把化学反应想象成"食物"和"细菌"的关系:
物质A(食物):
DA × ∇²A:食物在空间中扩散(从高浓度向低浓度流动)-A×B²:被细菌吃掉(B浓度越高,消耗越快,平方表示非线性增长)+f×(1-A):外界持续补充食物(浓度越低,补充越多)
物质B(细菌):
DB × ∇²B:细菌在空间中扩散(但比食物慢)+A×B²:细菌繁殖(吃了A之后变成B)-(k+f)×B:细菌自然死亡(固定衰减率)
💡 为什么会形成花纹?
- B在局部快速繁殖(吃掉A)
- A从周围扩散过来补充
- B扩散慢,只能在局部聚集
- 形成"B的斑点 + A的背景"的稳定结构
5.4 🎛️ 参数的物理意义
| 参数 | 典型值 | 物理意义 |
|---|---|---|
DA | 1.0 | A扩散得快("食物"容易流动) |
DB | 0.5 | B扩散得慢("细菌"不易移动) |
f | 0.02-0.06 | 供给速率(A的补充速度) |
k | 0.045-0.065 | 消耗速率(B的死亡速度) |
⚠️ 关键点:
f和k的微小变化会导致完全不同的图案!这是混沌系统的特征。
5.5 🔧 GLSL 实现
💡 数据存储:用纹理的红色通道存A,绿色通道存B,一次性计算两个物质。
#ifdef GL_ES
precision highp float;
#endif
uniform sampler2D u_buffer; // 上一帧状态(.r=A, .g=B)
uniform vec2 u_resolution;
uniform float u_feed; // f: 供给速率(推荐 0.02-0.06)
uniform float u_kill; // k: 消耗速率(推荐 0.045-0.065)
uniform float u_dt; // 时间步长(推荐 1.0,调快模拟速度可用 1.5)
// 扩散系数(通常固定)
const float DA = 1.0; // A扩散快
const float DB = 0.5; // B扩散慢
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 texel = 1.0 / u_resolution;
// ========== 步骤1: 读取当前浓度 ==========
vec2 current = texture2D(u_buffer, uv).rg; // .rg = 同时读取红绿通道
float A = current.r; // 红色通道存物质A
float B = current.g; // 绿色通道存物质B
// ========== 步骤2: 计算拉普拉斯算子(9点模板) ==========
// 为什么用9点?比4点更精确,能产生更细腻的花纹
vec2 laplacian = vec2(0.0);
// 中心点权重为 -1.0(拉普拉斯的标准形式)
laplacian += texture2D(u_buffer, uv).rg * -1.0;
// 4邻域(上下左右)权重 0.2
laplacian += texture2D(u_buffer, uv + vec2(-texel.x, 0.0)).rg * 0.2;
laplacian += texture2D(u_buffer, uv + vec2( texel.x, 0.0)).rg * 0.2;
laplacian += texture2D(u_buffer, uv + vec2(0.0, -texel.y)).rg * 0.2;
laplacian += texture2D(u_buffer, uv + vec2(0.0, texel.y)).rg * 0.2;
// 4对角邻域权重 0.05(更弱的影响)
laplacian += texture2D(u_buffer, uv + vec2(-texel.x, -texel.y)).rg * 0.05;
laplacian += texture2D(u_buffer, uv + vec2( texel.x, -texel.y)).rg * 0.05;
laplacian += texture2D(u_buffer, uv + vec2(-texel.x, texel.y)).rg * 0.05;
laplacian += texture2D(u_buffer, uv + vec2( texel.x, texel.y)).rg * 0.05;
// 权重总和:-1 + 4*0.2 + 4*0.05 = 0(这是拉普拉斯算子的性质)
float laplaceA = laplacian.r;
float laplaceB = laplacian.g;
// ========== 步骤3: 计算化学反应项 ==========
// A + 2B → 3B(A和B²反应,生成B)
float reaction = A * B * B; // 平方表示需要两个B分子参与
// ========== 步骤4: 应用 Gray-Scott 方程 ==========
// 物质A的变化率
float dA = DA * laplaceA // A的扩散
- reaction // A被消耗(转化为B)
+ u_feed * (1.0 - A); // A的补充(浓度越低补充越多)
// 物质B的变化率
float dB = DB * laplaceB // B的扩散
+ reaction // B的生成(从A转化而来)
- (u_kill + u_feed) * B; // B的衰减
// ========== 步骤5: 欧拉积分(时间推进) ==========
A += dA * u_dt; // 新浓度 = 旧浓度 + 变化率 × 时间步长
B += dB * u_dt;
// ========== 步骤6: 限制浓度范围 ==========
// 防止数值爆炸或变负
A = clamp(A, 0.0, 1.0);
B = clamp(B, 0.0, 1.0);
// ========== 步骤7: 输出到纹理 ==========
// .r存A, .g存B, .b不用, .a设为1
gl_FragColor = vec4(A, B, 0.0, 1.0);
}💡 性能优化:
- 如果不需要太精细的花纹,可以只用4点拉普拉斯(删掉4个对角)
u_dt可以设大一点(如1.5或2.0)加快演化速度,但太大会不稳定
⚠️ 常见问题:
- 全变黑/全变白:
u_dt太大,降低到0.5-1.0- 花纹不出现:初始化没有B的"种子",需要在中心点添加一些B
- 花纹很模糊:分辨率太低,建议至少512x512
5.6 🎨 初始化 & 着色
5.6.1 初始化:种下"种子"
💡 关键:反应扩散需要一个"触发点"。通常让A铺满整个空间,然后在某些位置添加少量B作为"种子",让花纹从这里生长。
uniform vec2 u_resolution;
// 伪随机数生成器
float random(vec2 st) {
return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453123);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// ========== 初始状态:A满,B空 ==========
float A = 1.0; // "食物"充满整个空间
float B = 0.0; // "细菌"初始为0
// ========== 方法1:中心圆形种子 ==========
vec2 toCenter = uv - vec2(0.5, 0.5); // 到中心的向量
float distToCenter = length(toCenter);
if(distToCenter < 0.1) { // 半径0.1的圆内
B = 1.0; // 放满细菌
}
// ========== 方法2:随机散点种子 ==========
// 取消上面的注释,用这个替代
// if(random(uv) > 0.995) { // 约0.5%的像素
// B = 1.0;
// }
// ========== 方法3:多个小圆形(推荐) ==========
// 在不同位置创建多个种子,会生成更复杂的图案
// vec2 seed1 = vec2(0.3, 0.3);
// vec2 seed2 = vec2(0.7, 0.6);
// if(length(uv - seed1) < 0.05 || length(uv - seed2) < 0.05) {
// B = 1.0;
// }
gl_FragColor = vec4(A, B, 0.0, 1.0);
}💡 实验建议:
- 大种子 → 大斑块花纹
- 多个小种子 → 细碎密集花纹
- 随机散点 → 不规则生长(最自然)
5.6.2 着色Shader:将数据可视化
uniform sampler2D u_simulation; // 模拟结果(.r=A, .g=B)
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 concentration = texture2D(u_simulation, uv).rg;
float A = concentration.r;
float B = concentration.g;
// ========== 根据B的浓度着色(渐变) ==========
vec3 color1 = vec3(0.1, 0.1, 0.3); // B=0: 深蓝(背景)
vec3 color2 = vec3(1.0, 0.5, 0.0); // B=0.5: 橙色(过渡)
vec3 color3 = vec3(1.0, 1.0, 0.8); // B=1: 亮黄(高浓度)
// 分段混合:0-0.5用color1→color2,0.5-1用color2→color3
vec3 color = mix(color1, color2, smoothstep(0.0, 0.5, B));
color = mix(color, color3, smoothstep(0.5, 1.0, B));
// ========== 可选:添加边缘高光 ==========
// 检测B浓度的梯度(边缘)
// float dx = dFdx(B); // B在X方向的变化率
// float dy = dFdy(B); // B在Y方向的变化率
// float edge = length(vec2(dx, dy));
// color += edge * 0.5; // 边缘发光
gl_FragColor = vec4(color, 1.0);
}💡 配色方案推荐:
豹纹:黄色 → 橙色 → 深棕
glslvec3 color1 = vec3(0.2, 0.15, 0.1); vec3 color2 = vec3(0.8, 0.5, 0.2); vec3 color3 = vec3(0.95, 0.85, 0.6);海洋:深蓝 → 青绿 → 白色泡沫
glslvec3 color1 = vec3(0.0, 0.1, 0.3); vec3 color2 = vec3(0.0, 0.5, 0.7); vec3 color3 = vec3(0.8, 0.9, 1.0);
5.7 🎛️ 参数探索:调出不同花纹
💡 重要:
f和k的微小变化(0.001级别)会导致完全不同的图案!这是混沌系统的魅力。
不同的 feed 和 kill 值会产生完全不同的图案:
| feed(f) | kill(k) | 图案类型 | 特征描述 |
|---|---|---|---|
| 0.037 | 0.060 | 🐚 珊瑚 | 分叉的树枝状结构 |
| 0.055 | 0.062 | 🌀 螺旋 | 旋转的漩涡图案 |
| 0.039 | 0.058 | 🦓 斑马纹 | 平行的条纹 |
| 0.026 | 0.051 | 🐆 豹纹 | 离散的圆形斑点 |
| 0.014 | 0.054 | 🐙 手指状 | 长条形分支 |
| 0.062 | 0.061 | 🧬 迷宫 | 复杂的迷宫结构 |
💡 调参技巧:
- 先固定一个参数:例如固定
k = 0.060,只调f- 小步长调整:每次 ±0.001,观察变化
- 记录好的组合:发现喜欢的图案立刻记下来,很难重现!
- 使用GUI:推荐用 dat.GUI 或 lil-gui 做实时调节界面
📚 推荐资源:
- Karl Sims 的交互式参数空间 - 可视化参数图
- Reaction-Diffusion Explorer - 在线实验工具
6. 🖌️ 实战案例4: 水彩效果
6.1 🎨 水彩的视觉特征
真实水彩画的物理特性(需要模拟的效果):
🌊 边缘累积 (Edge Darkening)
现象:颜料在干燥时向边缘流动,形成深色边界
原因:液体表面张力和蒸发导致颜料向凹陷处聚集💧 渗透扩散
现象:颜色在纸上自然晕开,形成柔和的渐变
原因:水分渗透到纤维中,带着颜料扩散🎭 纸张纹理
现象:底层纸张的纤维纹理可见
原因:颜料在纸张的凹凸不平处分布不均🌈 颜色混合
现象:多层水彩透明叠加,产生丰富色彩
原因:水彩是半透明的,光线穿透多层颜料
💡 模拟策略:我们不需要精确模拟物理,只需要视觉上接近。重点模拟边缘累积和扩散,就能达到80%的效果。
6.2 🔧 边缘累积实现
💡 原理:检测图像的边缘(明暗变化剧烈的地方),在边缘处增加颜料浓度,模拟颜料向边界聚集的效果。
uniform sampler2D u_buffer; // 当前颜料分布(迭代中的状态)
uniform sampler2D u_original; // 原始图像(用于检测边缘)
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 texel = 1.0 / u_resolution;
// 读取当前颜料分布
vec4 current = texture2D(u_buffer, uv);
// ========== 步骤1: 用Sobel算子检测边缘 ==========
// Sobel算子:通过对比周围像素,检测明暗变化率(梯度)
float gx = 0.0; // X方向梯度
float gy = 0.0; // Y方向梯度
// 遍历3x3邻域
for(int y = -1; y <= 1; y++) {
for(int x = -1; x <= 1; x++) {
vec2 offset = vec2(float(x), float(y)) * texel;
// 读取邻居的亮度(转灰度)
vec3 color = texture2D(u_original, uv + offset).rgb;
float intensity = dot(color, vec3(0.299, 0.587, 0.114)); // 加权平均
// Sobel卷积核权重
// X方向: [-1 0 1; -2 0 2; -1 0 1]
// Y方向: [-1 -2 -1; 0 0 0; 1 2 1]
gx += intensity * float(x) * (abs(y) == 1 ? 1.0 : 2.0);
gy += intensity * float(y) * (abs(x) == 1 ? 1.0 : 2.0);
}
}
// 梯度强度(边缘强度)
float edge = length(vec2(gx, gy));
// ========== 步骤2: 向边缘累积颜料 ==========
// 边缘强度越高,累积越多
float accumulation = edge * 0.1; // 0.1是累积系数,可调
// 增加颜料密度(颜色变深)
current.rgb *= (1.0 + accumulation);
// 防止过度累积(限制最大值)
current.rgb = min(current.rgb, vec3(1.5));
gl_FragColor = current;
}💡 Sobel算子简化理解:
- 想象用手指沿着图像扫过,碰到边缘时阻力最大
- Sobel就是量化这个"阻力",边缘处值大,平坦处值小
- 我们利用这个值来决定颜料累积的程度
⚠️ 注意:这个Shader需要迭代多次(例如10-20次),每次累积一点点,才能看到明显效果。
6.3 🌊 扩散模拟
💡 原理:模拟颜料在纸上晕开的效果,通过平均周围像素的颜色,让图像变柔和。
uniform sampler2D u_buffer; // 当前颜料分布
uniform vec2 u_resolution;
uniform float u_diffusionRate; // 扩散速率(0.1-0.3推荐)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 texel = 1.0 / u_resolution;
// 读取当前像素颜色
vec4 current = texture2D(u_buffer, uv);
// ========== 计算4邻域平均 ==========
// 模拟颜料向周围扩散
vec4 neighbors = vec4(0.0);
neighbors += texture2D(u_buffer, uv + vec2(-texel.x, 0.0)); // 左
neighbors += texture2D(u_buffer, uv + vec2( texel.x, 0.0)); // 右
neighbors += texture2D(u_buffer, uv + vec2(0.0, -texel.y)); // 下
neighbors += texture2D(u_buffer, uv + vec2(0.0, texel.y)); // 上
neighbors *= 0.25; // 平均值
// ========== 扩散:当前颜色和邻居平均混合 ==========
// u_diffusionRate越大,扩散越强,图像越模糊
vec4 diffused = mix(current, neighbors, u_diffusionRate);
// mix(a, b, t):
// - t=0 → 完全保留current(不扩散)
// - t=1 → 完全使用neighbors(极度模糊)
// - t=0.2 → 每次向邻居"靠拢"20%
gl_FragColor = diffused;
}💡 扩散强度控制:
u_diffusionRate = 0.1:轻微扩散,保留细节u_diffusionRate = 0.2:中等扩散,柔和边缘u_diffusionRate = 0.3:强扩散,梦幻效果> 0.5:过度模糊,失去细节
⚠️ 迭代次数:扩散也需要多次迭代(5-10次),每次轻微扩散,累积效果更自然。
6.4 🎭 完整水彩流程
将边缘累积、扩散、纹理叠加组合起来:
class WatercolorRenderer {
constructor(width, height) {
// ========== 创建多个渲染Pass ==========
this.edgeDarkeningPass = new EdgeDarkeningPass(); // 边缘累积
this.diffusionPass = new DiffusionPass(); // 扩散
this.textureBlendPass = new TextureBlendPass(); // 纸张纹理
// 中间缓冲
this.tempBuffer1 = new THREE.WebGLRenderTarget(width, height);
this.tempBuffer2 = new THREE.WebGLRenderTarget(width, height);
}
render(inputTexture, paperTexture) {
let currentBuffer = inputTexture;
// ========== 阶段1: 边缘累积(迭代10-20次) ==========
// 每次迭代让边缘稍微变暗
for(let i = 0; i < 15; i++) {
this.edgeDarkeningPass.render(currentBuffer, this.tempBuffer1);
// 交换缓冲区(ping-pong)
[currentBuffer, this.tempBuffer1] = [this.tempBuffer1, currentBuffer];
}
// ========== 阶段2: 扩散(迭代5-10次) ==========
// 让颜色自然晕开
for(let i = 0; i < 8; i++) {
this.diffusionPass.render(currentBuffer, this.tempBuffer2);
[currentBuffer, this.tempBuffer2] = [this.tempBuffer2, currentBuffer];
}
// ========== 阶段3: 叠加纸张纹理 ==========
// 用正片叠底或叠加模式混合纸张纹理
this.textureBlendPass.render(currentBuffer, paperTexture, this.tempBuffer1);
return this.tempBuffer1.texture;
}
}
// ========== 使用示例 ==========
const watercolor = new WatercolorRenderer(1024, 1024);
function processImage(originalImage, paperTexture) {
const result = watercolor.render(originalImage, paperTexture);
// 显示结果
displayMaterial.uniforms.u_texture.value = result;
renderer.setRenderTarget(null);
renderer.render(displayScene, camera);
}💡 性能优化建议:
- 降低分辨率处理(如原图1024,处理时512),最后再放大
- 减少迭代次数(边缘累积10次,扩散5次)
- 预计算纸张纹理,不要每帧重新生成
- 使用
WebGLRenderTarget的minFilter: THREE.LinearFilter加速采样
💡 进阶效果:
- 颜色抖动:叠加Perlin噪声,模拟颜料不均匀
- 笔触纹理:用法线贴图模拟笔刷的方向性
- 多层水彩:分开处理高光、中间调、暗部,最后合成
7. 🎓 本章总结
7.1 ✅ 你学到了什么
恭喜你完成了Shader高级模拟技术的学习!让我们回顾一下核心知识:
7.1.1 核心技术
🏓 乒乓缓冲 (Ping-Pong Buffer)
本质:用两个纹理来回切换,实现GPU的"记忆"功能
应用:所有需要保存历史状态的模拟(物理、生命游戏、流体等)🔄 多缓冲管理
双缓冲:读A写B,下一帧交换(适用于只需要上一帧的模拟)
三缓冲:previous-current-next循环(适用于需要两帧历史的模拟,如水波)
7.1.2 经典模拟案例
🎮 Conway生命游戏
学到了:细胞自动机的实现、邻居统计、规则判断
关键点:每个像素独立计算,但依赖周围8个邻居🌊 水波模拟
学到了:波动方程的离散化、拉普拉斯算子、三缓冲管理
关键点:需要两帧历史数据(当前和上一帧),才能计算惯性🧬 反应扩散系统
学到了:Gray-Scott模型、化学反应模拟、参数调优
关键点:微小的参数变化会导致完全不同的图案(混沌系统)🖌️ 水彩效果
学到了:边缘检测(Sobel)、扩散模拟、多Pass渲染
关键点:不需要精确物理模拟,视觉相似即可
7.1.3 数学工具箱
🧮 拉普拉斯算子 (∇²h)
物理意义:衡量一个点和周围的"差异"
计算方法:(上+下+左+右)/4 - 中心📐 差分方程
作用:把连续的微分方程转换为计算机能处理的离散形式
例子:波动方程 →h_new = 2×h_current - h_previous + laplacian×speed⏱️ 欧拉积分法
作用:根据变化率推算下一帧的值
公式:value_new = value_old + rate × dt
7.2 📚 推荐练习(从易到难)
💡 建议:按顺序完成练习,每个练习都会巩固本章的核心概念。
7.2.1 扩展生命游戏(难度:⭐⭐)
基础版:
- 实现其他细胞自动机规则(例如:HighLife、Replicator)
HighLife规则: - 活细胞:2-3邻居存活 - 死细胞:3或6邻居复活(比Conway多了6) - 添加"暂停/播放"和"单步执行"功能
- 实现"清空"和"随机填充"按钮
进阶版:
- 用颜色表示细胞年龄(新生=绿色,老年=红色)
- 统计活细胞数量,绘制折线图
- 实现预设图案库(滑翔机、太空船、振荡器等)
挑战版:
- 支持六边形网格(每个细胞6个邻居)
- 实现"历史回放"功能(保存每一帧)
7.2.2 交互式水波(难度:⭐⭐⭐)
基础版:
- 鼠标点击产生涟漪
- 添加"涟漪强度"和"波速"滑块
- 实现"清空水面"按钮
进阶版:
- 鼠标拖拽连续产生波(插值多个点)
- 多个手指/鼠标同时操作
- 水波遇到边界反弹(设置边界条件)
挑战版:
- 用法线扭曲背景图片(折射效果)
- 添加障碍物(岩石、岛屿),水波绕过
- 实现焦散效果(水波在地面的光影)
7.2.3. 自定义反应扩散(难度:⭐⭐⭐⭐)
基础版:
- 用GUI(dat.GUI或lil-gui)实时调节
f和k参数 - 保存/加载参数预设("豹纹"、"珊瑚"等)
- 截图功能(保存当前图案)
进阶版:
- 实现参数动画(
f和k随时间变化) - 多物质系统(3种化学物质:A、B、C)
- 用不同颜色通道存储不同物质
挑战版:
- 将反应扩散应用到3D模型表面(UV展开)
- 实现"局部参数"(不同区域用不同
f/k) - 生成高分辨率图案(4K),导出为纹理
7.2.4. 真实水彩模拟(难度:⭐⭐⭐⭐⭐)
基础版:
- 加载一张图片,应用水彩效果
- 调节边缘累积强度和扩散次数
- 对比原图和水彩效果
进阶版:
- 叠加真实纸张纹理(用噪声或照片)
- 实现笔触纹理(方向性的边缘累积)
- 分层处理:高光层、中间调、阴影层
挑战版:
- 实时水彩绘画工具(鼠标绘制,即时水彩化)
- 颜料混合模拟(红+蓝=紫,考虑透明度)
- 纸张吸水模拟(湿区扩散快,干区扩散慢)
7.3 🎯 实践项目建议
如果你想做一个完整项目,可以尝试:
生命游戏模拟器(Web应用)
- 提供多种规则选择
- 内置图案库
- 历史回放和导出GIF
交互式水波池(艺术装置)
- 触摸屏交互
- 背景折射(水下景观)
- 多人同时操作
纹理生成器(工具类)
- 反应扩散生成无缝纹理
- 参数自动探索(随机生成有趣图案)
- 导出PBR材质包
水彩滤镜应用(图像处理)
- 照片转水彩画
- 实时预览
- 批量处理
7.4 🔗 学习资源
理论基础:
- Conway's Game of Life - Wikipedia - 生命游戏详细规则
- Gray-Scott Model - Karl Sims - 反应扩散参数空间可视化
- GPU Gems - Fluid Simulation - NVIDIA官方流体模拟教程
代码示例:
- The Book of Shaders - GLSL入门和乒乓缓冲
- Shadertoy - 搜索"Game of Life"、"Reaction-Diffusion"查看实例
- Three.js Examples - WebGL渲染技术参考
在线工具:
- Golly - 生命游戏桌面软件(可运行超大规模模拟)
- Reaction-Diffusion Explorer - 交互式参数调节
- GLSL Sandbox - 在线Shader编辑器
继续探索GPU模拟的无限可能吧!记住:实践是最好的老师,动手写代码才能真正理解这些技术。
下一篇《光照与材质系统》见!✨