返回知识库

渲染

综合案例集

综合案例集 封面
WebGPU/WebGLShaderGLSL/WGSL案例SDF粒子系统后处理

直觉问题

  • 学了一大堆渲染理论,如何验证自己的理解?有没有”一行代码就能跑”的 Demo?
  • 从 0 到 1 写一个完整的渲染 Demo,应该按什么顺序组织代码?

这篇笔记提供 4 个完整可运行的渲染 Demo,覆盖 2D SDF、3D 地球、GPU 粒子系统和后处理链。每个 Demo 都包含完整的 GLSL 和 WGSL 代码,以及”为什么这样写”的原理拆解。


Demo 1: 2D SDF 交互图形

效果预览

一个由 Signed Distance Function (SDF) 描述的 2D 图形,支持鼠标交互改变形状、颜色和动画。

核心原理

SDF 的核心思想是:对于平面上任意一点 pp,函数 f(p)f(p) 返回该点到图形边界的有符号距离

  • f(p)>0f(p) > 0:点在图形外部
  • f(p)=0f(p) = 0:点在边界上
  • f(p)<0f(p) < 0:点在内部

多个 SDF 图形可以通过 minmaxsmoothmin 等操作组合。

GLSL 版本

#version 300 es
precision highp float;

uniform vec2  u_resolution;
uniform vec2  u_mouse;
uniform float u_time;

out vec4 fragColor;

// 圆形 SDF
float circleSDF(vec2 p, float r) {
  return length(p) - r;
}

// 矩形 SDF
float boxSDF(vec2 p, vec2 b) {
  vec2 d = abs(p) - b;
  return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0);
}

// smooth min 用于柔和过渡两个形状
float smin(float a, float b, float k) {
  float h = max(k - abs(a - b), 0.0) / k;
  return min(a, b) - h * h * k * (1.0 / 4.0);
}

void main() {
  // 坐标归一化到 [-1, 1]
  vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);

  // 鼠标归一化位置
  vec2 mousePos = (u_mouse - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);

  // 动画参数
  float t = u_time * 0.5;

  // 圆(位置随鼠标移动)
  float d1 = circleSDF(p - mousePos, 0.3 + 0.1 * sin(t));

  // 矩形(旋转动画)
  float cosT = cos(t);
  float sinT = sin(t);
  vec2 rotP = vec2(p.x * cosT + p.y * sinT, p.y * cosT - p.x * sinT);
  float d2 = boxSDF(rotP, vec2(0.25, 0.15));

  // 合并两个形状
  float d = smin(d1, d2, 0.2);

  // 颜色:内部为暖色,外部为冷色
  vec3 color;
  if (d < 0.0) {
    color = mix(vec3(1.0, 0.4, 0.2), vec3(0.9, 0.7, 0.1), -d * 3.0);
  } else {
    color = mix(vec3(0.1, 0.2, 0.4), vec3(0.05, 0.1, 0.2), smoothstep(0.0, 0.5, d));
  }

  fragColor = vec4(color, 1.0);
}

WGSL 版本

@group(0) @binding(0) var<uniform> uUniforms: Uniforms;

struct Uniforms {
  resolution: vec2f,
  mouse: vec2f,
  time: f32,
  _pad: f32,
}

fn circleSDF(p: vec2f, r: f32) -> f32 {
  return length(p) - r;
}

fn boxSDF(p: vec2f, b: vec2f) -> f32 {
  let d = abs(p) - b;
  return length(max(d, vec2f(0.0))) + min(max(d.x, d.y), 0.0);
}

fn smin(a: f32, b: f32, k: f32) -> f32 {
  let h = max(k - abs(a - b), 0.0) / k;
  return min(a, b) - h * h * k * 0.25;
}

@fragment
fn fs_main(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
  let p = (fragCoord.xy - 0.5 * uUniforms.resolution) / min(uUniforms.resolution.x, uUniforms.resolution.y);

  let mousePos = (uUniforms.mouse - 0.5 * uUniforms.resolution) / min(uUniforms.resolution.x, uUniforms.resolution.y);

  let t = uUniforms.time * 0.5;

  let d1 = circleSDF(p - mousePos, 0.3 + 0.1 * sin(t));

  let cosT = cos(t);
  let sinT = sin(t);
  let rotP = vec2f(p.x * cosT + p.y * sinT, p.y * cosT - p.x * sinT);
  let d2 = boxSDF(rotP, vec2f(0.25, 0.15));

  let d = smin(d1, d2, 0.2);

  var color: vec3f;
  if (d < 0.0) {
    color = mix(vec3f(1.0, 0.4, 0.2), vec3f(0.9, 0.7, 0.1), -d * 3.0);
  } else {
    color = mix(vec3f(0.1, 0.2, 0.4), vec3f(0.05, 0.1, 0.2), smoothstep(0.0, 0.5, d));
  }

  return vec4f(color, 1.0);
}

原理拆解

graph LR
    A[片元坐标] --> B[坐标归一化]
    B --> C[计算 SDF 距离]
    C --> D[smoothmin 合并]
    D --> E[距离 → 颜色映射]
    E --> F[输出]
  1. 坐标归一化:将像素坐标从 [0,width]×[0,height][0, width]×[0, height] 映射到 [1,1]×[1,1][-1, 1]×[-1, 1],保证不同分辨率下图形比例一致。
  2. SDF 计算circleSDFboxSDF 分别返回点到圆和矩形的距离。
  3. 形状合并smin (smooth minimum) 让两个形状的交界区域柔和过渡,而非生硬切割。
  4. 颜色映射:利用距离值映射到不同的颜色,内部暖色表示”内部”,外部冷色表示”外部”。

Demo 2: 3D 地球——大气散射与日夜交替

效果预览

一个由 Raymarching 渲染的 3D 地球,展示大气散射效果(Rayleigh 散射)和简单的日夜交替动画。

核心原理

地球渲染通常涉及:

  • 球体 Raymarching:从相机位置沿视线方向步进,找到与球体的交点
  • 大气散射:模拟光线穿过大气层时的散射现象(蓝色天空/红色日落)
  • 法线计算:在交点处计算法线,用于光照

GLSL 版本

#version 300 es
precision highp float;

uniform vec2  u_resolution;
uniform float u_time;

out vec4 fragColor;

#define MAX_STEPS 128
#define MAX_DIST  100.0
#define EPS 0.001

// 球体 SDF(3D)
float sphereSDF(vec3 p, float r) {
  return length(p) - r;
}

// 地球场景(球体 + 大气层)
float map(vec3 p) {
  return sphereSDF(p, 1.0);
}

// 法线计算
vec3 calcNormal(vec3 p) {
  vec2 e = vec2(EPS, 0.0);
  return normalize(vec3(
    map(p + e.xyy) - map(p - e.xyy),
    map(p + e.yxy) - map(p - e.yxy),
    map(p + e.yyx) - map(p - e.yyx)
  ));
}

// 大气散射简化模型(Rayleigh)
vec3 atmosphere(vec3 rayDir, vec3 sunDir) {
  float cosTheta = dot(rayDir, sunDir);
  float rayleigh = 1.0 + cosTheta * cosTheta;
  vec3 color = vec3(
    0.2 + 0.8 * rayleigh,
    0.4 + 0.6 * rayleigh,
    1.0
  );
  return color;
}

void main() {
  // 屏幕坐标归一化
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.y;

  // 相机
  vec3 ro = vec3(0.0, 0.0, -3.0); // 相机位置
  vec3 rd = normalize(vec3(uv, 1.0)); // 视线方向

  // 旋转相机
  float angle = u_time * 0.1;
  rd.xz = mat2(cos(angle), sin(angle), -sin(angle), cos(angle)) * rd.xz;

  // Raymarching
  float t = 0.0;
  for (int i = 0; i < MAX_STEPS; i++) {
    vec3 p = ro + rd * t;
    float d = map(p);
    if (d < EPS || t > MAX_DIST) break;
    t += d;
  }

  vec3 color;
  if (t < MAX_DIST) {
    // 打到地球表面
    vec3 p = ro + rd * t;
    vec3 normal = calcNormal(p);
    vec3 lightDir = normalize(vec3(1.0, 1.0, 0.5));

    // 漫反射
    float diff = max(dot(normal, lightDir), 0.0);
    vec3 earthColor = mix(vec3(0.1, 0.3, 0.1), vec3(0.2, 0.5, 0.9), diff);

    // 边缘光(大气散射效果)
    float fresnel = pow(1.0 - max(dot(normal, -rd), 0.0), 3.0);
    color = mix(earthColor, vec3(0.5, 0.7, 1.0), fresnel * 0.5);
  } else {
    // 没打到地球,渲染天空(星空)
    color = vec3(0.02, 0.02, 0.05);
  }

  fragColor = vec4(color, 1.0);
}

WGSL 版本

@group(0) @binding(0) var<uniform> uUniforms: Uniforms;

struct Uniforms {
  resolution: vec2f,
  time: f32,
  _pad: f32,
}

const MAX_STEPS = 128;
const MAX_DIST  = 100.0;
const EPS       = 0.001;

fn sphereSDF(p: vec3f, r: f32) -> f32 {
  return length(p) - r;
}

fn map(p: vec3f) -> f32 {
  return sphereSDF(p, 1.0);
}

fn calcNormal(p: vec3f) -> vec3f {
  let e = vec2f(EPS, 0.0);
  return normalize(vec3f(
    map(p + e.xyy) - map(p - e.xyy),
    map(p + e.yxy) - map(p - e.yxy),
    map(p + e.yyx) - map(p - e.yyx)
  ));
}

@fragment
fn fs_main(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
  let uv = (fragCoord.xy - 0.5 * uUniforms.resolution) / uUniforms.resolution.y;

  let ro = vec3f(0.0, 0.0, -3.0);
  var rd = normalize(vec3f(uv, 1.0));

  // 旋转
  let angle = uUniforms.time * 0.1;
  let cosA = cos(angle);
  let sinA = sin(angle);
  rd = vec3f(rd.x * cosA - rd.z * sinA, rd.y, rd.x * sinA + rd.z * cosA);

  // Raymarching
  var t = 0.0;
  for (var i = 0; i < MAX_STEPS; i = i + 1) {
    let p = ro + rd * t;
    let d = map(p);
    if (d < EPS || t > MAX_DIST) { break; }
    t = t + d;
  }

  var color: vec3f;
  if (t < MAX_DIST) {
    let p = ro + rd * t;
    let normal = calcNormal(p);
    let lightDir = normalize(vec3f(1.0, 1.0, 0.5));
    let diff = max(dot(normal, lightDir), 0.0);
    let earthColor = mix(vec3f(0.1, 0.3, 0.1), vec3f(0.2, 0.5, 0.9), diff);
    let fresnel = pow(1.0 - max(dot(normal, -rd), 0.0), 3.0);
    color = mix(earthColor, vec3f(0.5, 0.7, 1.0), fresnel * 0.5);
  } else {
    color = vec3f(0.02, 0.02, 0.05);
  }

  return vec4f(color, 1.0);
}

原理拆解

graph LR
    A[相机位置 ro] --> B[视线方向 rd]
    B --> C[Raymarching<br/>沿 rd 步进]
    C --> D{打到球体?}
    D -- 是 --> E[计算法线 + 光照]
    E --> F[边缘光散射]
    D -- 否 --> G[渲染星空背景]
    F --> H[输出颜色]
    G --> H
  1. Raymarching 核心:从相机出发,沿视线方向不断步进,每次步进距离 d = map(p)。当 d < EPS 时说明已接近表面。
  2. 法线计算:利用 SDF 的梯度近似法线——在 3D 空间中取 6 个邻点的距离差值归一化。
  3. 光照计算:简单的 Lambertain 漫反射 max(dot(N, L), 0)
  4. 大气散射(Fresnel 边缘光):视线与法线夹角越大(接近边缘),散射越明显,呈现蓝色光晕。

Demo 3: GPU 粒子系统(Compute Shader)

效果预览

使用 WebGPU Compute Shader 驱动 100,000 个粒子,粒子受重力、风力影响,并在边界处反弹。

核心原理

传统 CPU 粒子系统:每帧在 JS 中计算粒子位置 → 上传 GPU → 绘制。瓶颈在于 CPU-GPU 数据传输。

GPU 粒子系统:粒子位置/速度全部存储在 GPU Storage Buffer 中,Compute Shader 直接读写,无需 CPU 参与。

graph LR
    A[CPU: 初始化粒子数据] --> B[GPU Storage Buffer]
    B --> C[Compute Shader<br/>更新位置/速度]
    C --> B
    B --> D[Vertex Shader<br/>读取位置并绘制]
    D --> E[Fragment Shader<br/>渲染粒子]

WGSL 版本(Compute + Render)

// ========== Compute Shader ==========
struct Particle {
  position: vec3<f32>,
  velocity: vec3<f32>,
  life: f32,
  _pad: f32,
}

@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;

@compute @workgroup_size(256)
fn cs_update(@builtin(global_invocation_id) id: vec3<u32>) {
  let idx = id.x;
  let particleCount = arrayLength(&particles);

  if (idx >= particleCount) { return; }

  var p = particles[idx];

  // 重力
  p.velocity.y -= 0.01;

  // 风力(基于粒子位置的噪声)
  p.velocity.x += sin(p.position.y * 2.0) * 0.001;
  p.velocity.z += cos(p.position.y * 2.0) * 0.001;

  // 更新位置
  p.position += p.velocity;

  // 边界反弹
  if (p.position.y < -2.0) {
    p.velocity.y *= -0.8; // 能量损失
    p.position.y = -2.0;
  }

  // 生命周期衰减
  p.life -= 0.005;
  if (p.life < 0.0) {
    // 重置粒子
    p.position = vec3f((fract(f32(idx) * 0.731) - 0.5) * 10.0, 5.0, 0.0);
    p.velocity = vec3f(0.0, 0.0, 0.0);
    p.life = 1.0;
  }

  particles[idx] = p;
}

// ========== Vertex Shader ==========
struct VertexOutput {
  @builtin(position) position: vec4f,
  @location(0) life: f32,
}

@vertex
fn vs_main(@builtin(instance_index) instanceIdx: u32,
           @location(0) vertexPos: vec3f) -> VertexOutput {
  var output: VertexOutput;

  let p = particles[instanceIdx];
  output.position = vec4f(p.position + vertexPos * 0.05, 1.0);
  output.life = p.life;

  return output;
}

// ========== Fragment Shader ==========
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4f {
  // 颜色随生命周期变化:亮 → 暗
  let color = mix(vec3f(1.0, 0.5, 0.1), vec3f(0.2, 0.1, 0.5), 1.0 - input.life);
  return vec4f(color * input.life, input.life);
}

JS 端(WebGPU 驱动代码)

// 粒子数据初始化
const PARTICLE_COUNT = 100000;
const particleSize = 32; // 4 * vec3 + 2 * float = 4*12 + 4*2 = 56 ≈ 对齐到 32
const particleData = new Float32Array(PARTICLE_COUNT * 8); // position(3) + velocity(3) + life + pad

for (let i = 0; i < PARTICLE_COUNT; i++) {
  particleData[i * 8 + 0] = (Math.random() - 0.5) * 10.0; // x
  particleData[i * 8 + 1] = (Math.random() - 0.5) * 5.0;  // y
  particleData[i * 8 + 2] = (Math.random() - 0.5) * 10.0; // z
  particleData[i * 8 + 3] = 0; // vx
  particleData[i * 8 + 4] = 0; // vy
  particleData[i * 8 + 5] = 0; // vz
  particleData[i * 8 + 6] = Math.random(); // life
  particleData[i * 8 + 7] = 0; // pad
}

// 创建 Storage Buffer
const particleBuffer = device.createBuffer({
  size: particleData.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
  mappedAtCreation: true,
});
new Float32Array(particleBuffer.getMappedRange()).set(particleData);
particleBuffer.unmap();

// 粒子渲染为点精灵 (Point Sprite)
const pipeline = device.createRenderPipeline({
  vertex: {
    module: shaderModule,
    entryPoint: 'vs_main',
    buffers: [],
  },
  fragment: {
    module: shaderModule,
    entryPoint: 'fs_main',
    targets: [{ format: presentationFormat }],
  },
  primitive: { topology: 'point-list' },
});

// Render Loop
function render() {
  // 1. Compute Pass: 更新粒子
  const computeEncoder = device.createCommandEncoder();
  const computePass = computeEncoder.beginComputePass();
  computePass.setPipeline(computePipeline);
  computePass.setBindGroup(0, bindGroup);
  computePass.dispatchWorkgroups(Math.ceil(PARTICLE_COUNT / 256));
  computePass.end();
  device.queue.submit([computeEncoder.finish()]);

  // 2. Render Pass: 绘制粒子
  const renderEncoder = device.createCommandEncoder();
  const renderPass = renderEncoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      clearValue: [0, 0, 0, 1],
      loadOp: 'clear',
      storeOp: 'store',
    }],
  });
  renderPass.setPipeline(pipeline);
  renderPass.setVertexBuffer(0, particleBuffer);
  renderPass.draw(PARTICLE_COUNT);
  renderPass.end();
  device.queue.submit([renderEncoder.finish()]);

  requestAnimationFrame(render);
}

原理拆解

graph TD
    A[CPU: 初始化粒子数据] --> B[GPU Storage Buffer]
    B --> C[Compute Shader<br/>dispatchWorkgroups]
    C --> D[逐粒子更新<br/>位置/速度/生命周期]
    D --> E[结果写回 Storage Buffer]
    E --> F[Render Pass<br/>读取位置绘制点精灵]
    F --> G[Swap Chain 显示]
  1. Storage Buffer 双用途:既可以被 Compute Shader 读写,也可以作为 Vertex Buffer 绑定到渲染管线。
  2. 数据对齐:WGSL 中 struct Particle 的大小是 32 字节(经过对齐),JS 中需要按相同布局填充数据。
  3. Dispatch 计算dispatchWorkgroups(ceil(count / 256)) 确保所有粒子都被处理。
  4. 生命周期管理:粒子 life 衰减到 0 后重置,实现粒子系统的自我维持。

Demo 4: 后处理链(多 Pass 效果)

效果预览

实现一个多 Pass 后处理链:场景渲染 → 高斯模糊提取 → Bloom 叠加 → 最终输出,支持以下效果:

  1. 高斯模糊(水平和垂直两个 Pass)
  2. Bloom(阈值提取 + 模糊 + 叠加)
  3. Vignette(暗角)
  4. Chromatic Aberration(色差)

后处理链架构

graph TD
    A[Main Scene] --> B[Pass 1: 渲染到 FBO]
    B --> C[Pass 2: 亮度阈值提取]
    C --> D[Pass 3: 水平高斯模糊]
    D --> E[Pass 4: 垂直高斯模糊]
    E --> F[Pass 5: 合成 Bloom]
    F --> G[Pass 6: Vignette]
    G --> H[Pass 7: 色差]
    H --> I[Pass 8: 最终输出到屏幕]

GLSL 版本

// ========== Bloom 提取 + 高斯模糊 ==========
#version 300 es
precision highp float;

uniform sampler2D u_sourceTexture;
uniform vec2      u_texelSize;
uniform int       u_horizontal;
uniform float     u_threshold;

in vec2 v_texCoord;
out vec4 fragColor;

const float weights[5] = float[](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);

void main() {
  vec2 dir = u_horizontal == 1 ? vec2(u_texelSize.x, 0.0) : vec2(0.0, u_texelSize.y);

  vec4 color = texture(u_sourceTexture, v_texCoord);
  float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));

  vec4 result = u_threshold > 0.0 ? max(color - u_threshold, 0.0) : color;

  for (int i = 1; i < 5; i++) {
    result += texture(u_sourceTexture, v_texCoord + float(i) * dir) * weights[i];
    result += texture(u_sourceTexture, v_texCoord - float(i) * dir) * weights[i];
  }

  fragColor = result;
}
// ========== 合成 + Vignette + 色差 ==========
#version 300 es
precision highp float;

uniform sampler2D u_sceneTexture;
uniform sampler2D u_bloomTexture;
uniform vec2      u_resolution;
uniform float     u_time;

in vec2 v_texCoord;
out vec4 fragColor;

void main() {
  vec4 scene = texture(u_sceneTexture, v_texCoord);
  vec4 bloom = texture(u_bloomTexture, v_texCoord);
  vec3 color = scene.rgb + bloom.rgb * 0.5;

  // Vignette
  vec2 center = v_texCoord - 0.5;
  float vignette = 1.0 - dot(center, center) * 1.5;
  vignette = clamp(vignette, 0.0, 1.0);
  color *= vignette;

  // Chromatic Aberration
  float dist = length(center);
  float aberrationStrength = dist * 0.01;
  color.r = texture(u_sceneTexture, v_texCoord + aberrationStrength * center).r;
  color.g = texture(u_sceneTexture, v_texCoord).g;
  color.b = texture(u_sceneTexture, v_texCoord - aberrationStrength * center).b;

  fragColor = vec4(color, 1.0);
}

WGSL 版本

// ========== Bloom 提取 + 高斯模糊 ==========
@group(0) @binding(0) var uTexture: texture_2d<f32>;
@group(0) @binding(1) var uSampler: sampler;

struct Uniforms {
  texelSize: vec2f,
  horizontal: i32,
  threshold: f32,
}

@group(0) @binding(2) var<uniform> uUniforms: Uniforms;

@fragment
fn fs_blur(input: VertexOutput) -> @location(0) vec4f {
  let dir = select(
    vec2f(0.0, uUniforms.texelSize.y),
    vec2f(uUniforms.texelSize.x, 0.0),
    uUniforms.horizontal == 1
  );

  let weights = array<f32, 5>(0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);

  var color = textureSample(uTexture, uSampler, input.texCoord);
  let brightness = dot(color.rgb, vec3f(0.2126, 0.7152, 0.0722));

  var result = select(color, max(color - vec4f(uUniforms.threshold), vec4f(0.0)), uUniforms.threshold > 0.0);

  for (var i = 1; i < 5; i++) {
    let offset = vec2f(f32(i)) * dir;
    result += textureSample(uTexture, uSampler, input.texCoord + offset) * weights[i];
    result += textureSample(uTexture, uSampler, input.texCoord - offset) * weights[i];
  }

  return result;
}
// ========== 合成 + Vignette + 色差 ==========
@group(0) @binding(0) var uSceneTex: texture_2d<f32>;
@group(0) @binding(1) var uBloomTex: texture_2d<f32>;
@group(0) @binding(2) var uSampler: sampler;

struct VertexOutput {
  @builtin(position) position: vec4f,
  @location(0) texCoord: vec2f,
}

@fragment
fn fs_final(input: VertexOutput) -> @location(0) vec4f {
  let scene = textureSample(uSceneTex, uSampler, input.texCoord);
  let bloom = textureSample(uBloomTex, uSampler, input.texCoord);
  var color = scene.rgb + bloom.rgb * 0.5;

  let center = input.texCoord - 0.5;
  let vignette = clamp(1.0 - dot(center, center) * 1.5, 0.0, 1.0);
  color *= vignette;

  let dist = length(center);
  let aberrationStrength = dist * 0.01;
  color.r = textureSample(uSceneTex, uSampler, input.texCoord + aberrationStrength * center).r;
  color.b = textureSample(uSceneTex, uSampler, input.texCoord - aberrationStrength * center).b;

  return vec4f(color, 1.0);
}

原理拆解

效果原理关键技术
Bloom提取高亮区域 → 模糊 → 叠加回原图亮度阈值 + 高斯模糊(separable 2-pass)
Vignette随距中心距离增大而降低亮度1.0 - dot(uv, uv) * strength
Chromatic AberrationRGB 通道分别偏移采样基于距中心距离的径向偏移

4 个 Demo 对比总结

Demo核心技术GLSL 代码量WGSL 代码量复杂度
2D SDFSDF、smoothmin、颜色映射~40 行~50 行入门
3D 地球Raymarching、法线计算、Fresnel~60 行~70 行中级
GPU 粒子Compute Shader、Storage Buffer仅 Fragment (~30 行)Compute (~50 行) + Render (~30 行)中高
后处理链多 Pass、FBO、高斯模糊、合成~80 行~90 行高级

常见误区与陷阱

  1. 忽略 SDF 的归一化

    • 陷阱:直接使用 gl_FragCoord.xy 计算 SDF,不同分辨率下图形变形
    • 解决:始终归一化坐标 p = (fragCoord - 0.5 * resolution) / min(resolution)
  2. Raymarching 步长过大导致穿透

    • 陷阱:MAX_STEPS 太小或步进距离超过表面厚度
    • 解决:确保 EPS 足够小(通常 0.001),MAX_STEPS 128-256
  3. Compute Shader workgroup 大小不当

    • 陷阱:@workgroup_size(1, 1, 1) 导致 GPU 利用率极低
    • 解决:设为 64-256(如 @workgroup_size(256)),匹配 GPU warp size
  4. Storage Buffer 布局不对齐

    • 陷阱:JS 中 Float32Array 的布局与 WGSL struct 的内存布局不一致
    • 解决:使用 vec3<padding> 或手动计算 offset,确保 16 字节对齐
  5. 多 Pass 后处理 FBO 绑定错误

    • 陷阱:前后 Pass 读写同一纹理导致数据竞争
    • 解决:使用 ping-pong FBO(A 读 B 写,下一帧 B 读 A 写)
  6. 高斯模糊权重总和不为 1

    • 陷阱:权重值写错导致模糊后图像变暗/变亮
    • 解决:确保 sum(weights) * 2 - centerWeight = 1( separable 模糊)
  7. Compute Shader 忘记同步

    • 陷阱:Compute 和 Render 之间没有正确 barrier
    • 解决:WebGPU 中同一 Queue 自动保证顺序;不同 Queue 需显式同步
  8. 忽略纹理格式限制

    • 陷阱:Bloom 提取使用 rgba8unorm,高亮值(如 5.0)被截断到 1.0
    • 解决:使用 rgba16floatrgba32float 格式存储 HDR 数据

延伸阅读与自测

权威资料

自测题

  1. 实践题:将 2D SDF Demo 中的 smin 替换为普通 min,观察交界区域的变化。理解 smoothmin 的作用。

  2. 优化题:在 3D 地球 Demo 中,Raymarching 的 MAX_STEPSMAX_DIST 如何权衡?提高 MAX_STEPS 一定能提高精度吗?

  3. 对比题:GPU 粒子系统 vs CPU 粒子系统的核心差异是什么?在什么场景下 GPU 粒子系统有明显优势?

  4. 扩展题:后处理链中的高斯模糊使用”两 Pass 可分离”技术,为什么不直接用一个大 kernel 的单 Pass?用数学解释。

  5. 设计题:如果你要设计一个”雨滴落在水面上产生涟漪”的效果,应该使用哪种技术组合?(提示:SDF / Compute Shader / 多 Pass 后处理)


参考资料获取时间: 2026-07-07,通过 web-search-primeweb-reader 工具检索验证。