Skip to content

🧪 Shader 进阶 - 高级模拟技术与交互系统 ​

1. 🌟 突破Shader的限制 - 状态保存的挑战 ​

1.1 为什么普通Shader无法模拟复杂现象? ​

在之前的文章中,所有效果都是无状态的 (Stateless),也就是说每一帧的计算都是完全独立的:

glsl
// 典型的无状态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 工作流程详解 ​

规则(按顺序执行):

  1. 🔵 读取:从 Texture A 读取上一帧的状态数据(例如:每个像素的速度、温度、颜色等)
  2. 🧮 计算:Shader 根据读取的数据和物理规则,计算出新的状态
  3. 🟢 写入:把计算结果写入 Texture B(不能写回A,因为其他像素还在读A!)
  4. 🔄 交换:下一帧把 A 和 B 的角色互换,B 变成新的"读取源",A 变成新的"写入目标"

⚠️ 为什么需要两个纹理?
如果只用一个纹理,会出现"读写冲突":当你在计算第100个像素时,第1个像素已经被更新了,导致读取的数据不一致。用两个纹理可以保证读取的是完整的上一帧数据。

🔧 Three.js 实现骨架 ​

💡 前置知识:WebGLRenderTarget 是Three.js中的"离屏画布",可以把渲染结果保存到纹理中,而不是直接显示在屏幕上。

javascript
// 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 → 交换AB
  • getCurrentTexture() 返回的是"最新计算结果",可以用于显示或进一步处理

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个像素的值来统计邻居数量,然后应用生命游戏规则。

glsl
#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 初始化随机状态 ​

glsl
// 初始化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(美化输出) ​

glsl
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中添加鼠标交互,让用户可以"画"出活细胞:

glsl
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个!

glsl
#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

javascript
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) ​

💡 渲染原理:高度场本身是看不见的(只是数字),我们需要:

  1. 把高度转换为法线(表面朝向)
  2. 用光照模型计算颜色
glsl
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坐标

    glsl
    vec2 distortion = normal.xy * 0.1;
    vec3 background = texture2D(u_background, uv + distortion).rgb;
    color = mix(waterColor, background, 0.5);
  • 菲涅尔效果:边缘更透明,中心更反射

    glsl
    float fresnel = pow(1.0 - max(0.0, dot(normal, viewDir)), 3.0);
    color = mix(waterColor, reflectionColor, fresnel);
  • 深度颜色:根据高度映射不同颜色(浅水区 = 青色,深水区 = 深蓝)

    glsl
    vec3 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 🎛️ 参数的物理意义 ​

参数典型值物理意义
DA1.0A扩散得快("食物"容易流动)
DB0.5B扩散得慢("细菌"不易移动)
f0.02-0.06供给速率(A的补充速度)
k0.045-0.065消耗速率(B的死亡速度)

⚠️ 关键点:f 和 k 的微小变化会导致完全不同的图案!这是混沌系统的特征。

5.5 🔧 GLSL 实现 ​

💡 数据存储:用纹理的红色通道存A,绿色通道存B,一次性计算两个物质。

glsl
#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作为"种子",让花纹从这里生长。

glsl
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:将数据可视化 ​

glsl
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);
}

💡 配色方案推荐:

  • 豹纹:黄色 → 橙色 → 深棕

    glsl
    vec3 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);
  • 海洋:深蓝 → 青绿 → 白色泡沫

    glsl
    vec3 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.0370.060🐚 珊瑚分叉的树枝状结构
0.0550.062🌀 螺旋旋转的漩涡图案
0.0390.058🦓 斑马纹平行的条纹
0.0260.051🐆 豹纹离散的圆形斑点
0.0140.054🐙 手指状长条形分支
0.0620.061🧬 迷宫复杂的迷宫结构

💡 调参技巧:

  1. 先固定一个参数:例如固定 k = 0.060,只调 f
  2. 小步长调整:每次 ±0.001,观察变化
  3. 记录好的组合:发现喜欢的图案立刻记下来,很难重现!
  4. 使用GUI:推荐用 dat.GUI 或 lil-gui 做实时调节界面

📚 推荐资源:


6. 🖌️ 实战案例4: 水彩效果 ​

6.1 🎨 水彩的视觉特征 ​

真实水彩画的物理特性(需要模拟的效果):

  1. 🌊 边缘累积 (Edge Darkening)
    现象:颜料在干燥时向边缘流动,形成深色边界
    原因:液体表面张力和蒸发导致颜料向凹陷处聚集

  2. 💧 渗透扩散
    现象:颜色在纸上自然晕开,形成柔和的渐变
    原因:水分渗透到纤维中,带着颜料扩散

  3. 🎭 纸张纹理
    现象:底层纸张的纤维纹理可见
    原因:颜料在纸张的凹凸不平处分布不均

  4. 🌈 颜色混合
    现象:多层水彩透明叠加,产生丰富色彩
    原因:水彩是半透明的,光线穿透多层颜料

💡 模拟策略:我们不需要精确模拟物理,只需要视觉上接近。重点模拟边缘累积和扩散,就能达到80%的效果。

6.2 🔧 边缘累积实现 ​

💡 原理:检测图像的边缘(明暗变化剧烈的地方),在边缘处增加颜料浓度,模拟颜料向边界聚集的效果。

glsl
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 🌊 扩散模拟 ​

💡 原理:模拟颜料在纸上晕开的效果,通过平均周围像素的颜色,让图像变柔和。

glsl
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 🎭 完整水彩流程 ​

将边缘累积、扩散、纹理叠加组合起来:

javascript
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 🎯 实践项目建议 ​

如果你想做一个完整项目,可以尝试:

  1. 生命游戏模拟器(Web应用)

    • 提供多种规则选择
    • 内置图案库
    • 历史回放和导出GIF
  2. 交互式水波池(艺术装置)

    • 触摸屏交互
    • 背景折射(水下景观)
    • 多人同时操作
  3. 纹理生成器(工具类)

    • 反应扩散生成无缝纹理
    • 参数自动探索(随机生成有趣图案)
    • 导出PBR材质包
  4. 水彩滤镜应用(图像处理)

    • 照片转水彩画
    • 实时预览
    • 批量处理

7.4 🔗 学习资源 ​

理论基础:

代码示例:

在线工具:


继续探索GPU模拟的无限可能吧!记住:实践是最好的老师,动手写代码才能真正理解这些技术。

下一篇《光照与材质系统》见!✨