JS `WebGPU` `Ray Tracing` `Denoising Algorithms` (`NLM`, `SVGF`)

各位观众老爷,晚上好!今天咱们来聊聊WebGPU和Ray Tracing的那些事儿,顺便再扒一扒降噪算法(NLM和SVGF)的底裤。准备好了吗?系好安全带,发车啦!

WebGPU:新时代的图形引擎

首先,WebGPU是什么?简单来说,它是下一代的Web图形API,旨在取代WebGL。WebGL虽然功不可没,但它毕竟是基于OpenGL ES,受限于OpenGL的历史包袱,效率和功能上都有些捉襟见肘。WebGPU的目标是提供更低的开销、更现代的GPU功能,以及更强大的并行计算能力。

想象一下,WebGL就像一辆老旧的自行车,虽然也能骑,但速度慢,维护麻烦。而WebGPU则是一辆配备了涡轮增压发动机的跑车,性能强大,而且更省油!

WebGPU的核心概念包括:

  • Devices (设备):代表一个GPU。
  • Queues (队列):用于提交命令。
  • Buffers (缓冲区):用于存储数据,比如顶点数据、纹理数据。
  • Textures (纹理):用于存储图像数据。
  • Samplers (采样器):用于控制纹理的采样方式。
  • Shaders (着色器):用WGSL(WebGPU Shading Language)编写的程序,在GPU上运行。
  • Render Pipelines (渲染管线):定义了渲染的流程。
  • Compute Pipelines (计算管线):定义了计算的流程。
  • Bind Groups (绑定组):将资源(比如缓冲区、纹理)绑定到着色器。

Ray Tracing:光线追踪,让你的像素更逼真

Ray Tracing,又称光线追踪,是一种模拟光线在场景中传播的渲染技术。与传统的光栅化渲染不同,光线追踪从摄像机出发,向场景中发射光线,追踪光线与物体的交点,并根据光照模型计算像素的颜色。

光栅化渲染就像用油漆桶往墙上泼油漆,虽然速度快,但细节粗糙。光线追踪则像用画笔一笔一笔地描绘,虽然速度慢,但效果细腻,能产生逼真的阴影、反射和折射效果。

Ray Tracing的基本步骤:

  1. 发射光线 (Ray Generation):从摄像机位置,为每个像素发射一条或多条光线。
  2. 相交测试 (Intersection Test):判断光线与场景中的物体是否相交。
  3. 着色 (Shading):如果光线与物体相交,则根据光照模型计算交点处的颜色。这通常包括计算直接光照、反射光照、折射光照等。
  4. 递归追踪 (Recursive Tracing):对于反射和折射光线,继续递归地进行光线追踪,直到达到最大追踪深度或光线能量衰减到一定程度。

WebGPU中的Ray Tracing

WebGPU本身并没有直接提供光线追踪API,但我们可以利用WebGPU的Compute Shader来实现光线追踪。思路是:

  1. 在Compute Shader中模拟光线追踪算法。
  2. 将渲染结果写入纹理。
  3. 在渲染管线中将纹理绘制到屏幕上。

下面是一个简单的WebGPU光线追踪示例代码(伪代码):

// WGSL着色器代码 (ray_tracer.wgsl)
struct Ray {
    origin: vec3f,
    direction: vec3f,
};

struct HitPayload {
    hit: bool,
    distance: f32,
    normal: vec3f,
    position: vec3f,
};

@group(0) @binding(0) var<storage, read_only> triangles: array<vec3f>; //三角形数据
@group(0) @binding(1) var<storage, read_only> normals: array<vec3f>; //三角形法线数据
@group(0) @binding(2) var<storage, read_write> outputTexture: texture_storage_2d<rgba8unorm, write>; //输出纹理

//光线与三角形相交测试
fn rayTriangleIntersect(ray: Ray, v0: vec3f, v1: vec3f, v2: vec3f) -> HitPayload {
    let epsilon = 0.000001;
    let edge1 = v1 - v0;
    let edge2 = v2 - v0;

    let h = cross(ray.direction, edge2);
    let a = dot(edge1, h);

    if (a > -epsilon && a < epsilon) {
        return HitPayload(false, 0.0, vec3f(0.0), vec3f(0.0)); // 光线平行于三角形
    }

    let f = 1.0 / a;
    let s = ray.origin - v0;
    let u = f * dot(s, h);

    if (u < 0.0 || u > 1.0) {
        return HitPayload(false, 0.0, vec3f(0.0), vec3f(0.0));
    }

    let q = cross(s, edge1);
    let v = f * dot(ray.direction, q);

    if (v < 0.0 || u + v > 1.0) {
        return HitPayload(false, 0.0, vec3f(0.0), vec3f(0.0));
    }

    // 计算 t, 光线参数
    let t = f * dot(edge2, q);

    if (t > epsilon) {
        let hitPosition = ray.origin + t * ray.direction;
        return HitPayload(true, t, vec3f(0.0), hitPosition); //暂时不计算法线
    } else {
        return HitPayload(false, 0.0, vec3f(0.0), vec3f(0.0));
    }
}

@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) global_id: vec3u) {
    let resolution = textureDimensions(outputTexture);
    let uv = vec2f(f32(global_id.x) / f32(resolution.x), f32(global_id.y) / f32(resolution.y));

    //生成光线
    let aspectRatio = f32(resolution.x) / f32(resolution.y);
    let cameraOrigin = vec3f(0.0, 0.0, 3.0);
    let cameraDirection = normalize(vec3f(uv.x * 2.0 - 1.0 * aspectRatio, uv.y * 2.0 - 1.0, -1.0));
    let ray = Ray(cameraOrigin, cameraDirection);

    var closestHit = HitPayload(false, 1000.0, vec3f(0.0), vec3f(0.0)); //假设最大距离为1000

    //遍历所有三角形,找到最近的交点
    for (var i: u32 = 0u; i < arrayLength(&triangles); i = i + 3u) {
        let v0 = triangles[i];
        let v1 = triangles[i + 1u];
        let v2 = triangles[i + 2u];
        let hit = rayTriangleIntersect(ray, v0, v1, v2);

        if (hit.hit && hit.distance < closestHit.distance) {
            closestHit = hit;
        }
    }

    var color = vec4f(0.0, 0.0, 0.0, 1.0); //默认背景色

    if (closestHit.hit) {
        // 简单着色,根据距离设置颜色
        let distanceColor = 1.0 - closestHit.distance / 10.0;
        color = vec4f(distanceColor, distanceColor, distanceColor, 1.0);
    }

    textureStore(outputTexture, vec2i(global_id.xy), color);
}
// JavaScript 代码
async function runRayTracer() {
  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();

  const canvas = document.getElementById("canvas");
  const context = canvas.getContext("webgpu");
  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();

  context.configure({
    device: device,
    format: presentationFormat,
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
    alphaMode: "opaque",
  });

  const resolution = [canvas.width, canvas.height];

  // 创建纹理
  const outputTexture = device.createTexture({
    size: resolution,
    format: "rgba8unorm",
    usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
  });

  const outputTextureView = outputTexture.createView();

  // 创建三角形数据
  const triangles = new Float32Array([
    -0.5, -0.5, -1.0,  // v0
     0.5, -0.5, -1.0,  // v1
     0.0,  0.5, -1.0   // v2
  ]);

  const triangleBuffer = device.createBuffer({
    size: triangles.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
    mappedAtCreation: true,
  });
  new Float32Array(triangleBuffer.getMappedRange()).set(triangles);
  triangleBuffer.unmap();

  // 创建绑定组布局
  const bindGroupLayout = device.createBindGroupLayout({
    entries: [
      {
        binding: 0,
        visibility: GPUShaderStage.COMPUTE,
        storageTexture: {
          access: "read-write",
          format: "rgba8unorm",
        },
      },
      {
        binding: 1,
        visibility: GPUShaderStage.COMPUTE,
        buffer: {
          type: "storage",
          hasDynamicOffset: false,
          minBindingSize: 0,
        },
      },
      {
        binding: 2,
        visibility: GPUShaderStage.COMPUTE,
        buffer: {
          type: "storage",
          hasDynamicOffset: false,
          minBindingSize: 0,
        },
      },
    ],
  });

  // 创建绑定组
  const bindGroup = device.createBindGroup({
    layout: bindGroupLayout,
    entries: [
      {
        binding: 0,
        resource: outputTextureView,
      },
      {
        binding: 1,
        resource: {
          buffer: triangleBuffer,
        },
      },
      {
        binding: 2,
        resource: {
          buffer: triangleBuffer,
        },
      },
    ],
  });

  // 创建计算管线
  const shaderModule = device.createShaderModule({
    code: shaderCode, // 上面的 WGSL 代码
  });

  const computePipeline = device.createComputePipeline({
    layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
    compute: {
      module: shaderModule,
      entryPoint: "main",
    },
  });

  // 创建渲染管线,将计算结果绘制到屏幕上
  const renderShaderModule = device.createShaderModule({
    code: `
      @vertex
      fn vertexMain(@builtin(vertex_index) vertexIndex : u32) -> @builtin(position) vec4f {
          let pos = array(
              vec2f(-1.0, -1.0), vec2f( 1.0, -1.0), vec2f(-1.0,  1.0),
              vec2f( 1.0, -1.0), vec2f(-1.0,  1.0), vec2f( 1.0,  1.0)
          );
          return vec4f(pos[vertexIndex], 0.0, 1.0);
      }

      @fragment
      fn fragmentMain() -> @location(0) vec4f {
          return textureSampleLevel(myTexture, mySampler, vec2f(0.5, 0.5), 0.0);
      }
    `,
  });

  const renderPipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: {
      module: renderShaderModule,
      entryPoint: "vertexMain",
    },
    fragment: {
      module: renderShaderModule,
      entryPoint: "fragmentMain",
      targets: [{ format: presentationFormat }],
    },
    primitive: {
      topology: "triangle-list",
    },
  });

  const sampler = device.createSampler({
    magFilter: "linear",
    minFilter: "linear",
  });

  const renderBindGroup = device.createBindGroup({
    layout: renderPipeline.getBindGroupLayout(0),
    entries: [
      {
        binding: 0,
        resource: sampler,
      },
      {
        binding: 1,
        resource: outputTextureView,
      },
    ],
  });

  // 执行计算管线
  const commandEncoder = device.createCommandEncoder();
  const computePass = commandEncoder.beginComputePass();
  computePass.setPipeline(computePipeline);
  computePass.setBindGroup(0, bindGroup);
  computePass.dispatchWorkgroups(Math.ceil(resolution[0] / 8), Math.ceil(resolution[1] / 8));
  computePass.end();

  // 执行渲染管线
  const textureView = context.getCurrentTexture().createView();
  const renderPass = commandEncoder.beginRenderPass({
    colorAttachments: [
      {
        view: textureView,
        clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
        loadOp: "clear",
        storeOp: "store",
      },
    ],
  });
  renderPass.setPipeline(renderPipeline);
  renderPass.setBindGroup(0, renderBindGroup);
  renderPass.draw(6, 1, 0, 0);
  renderPass.end();

  device.queue.submit([commandEncoder.finish()]);
}

runRayTracer();

注意: 这只是一个极其简化的示例。实际的光线追踪场景会复杂得多,需要考虑更多的光照效果、材质属性、加速结构(比如BVH)等。

降噪算法:让你的光线追踪更干净

光线追踪的渲染结果通常会带有大量的噪声,尤其是在采样数量较少的情况下。这是因为每个像素的光线数量有限,无法完全模拟真实的光照情况。为了解决这个问题,我们需要使用降噪算法来平滑渲染结果,减少噪声。

这里我们介绍两种常用的降噪算法:NLM (Non-Local Means) 和 SVGF (Spatiotemporal Variance-Guided Filtering)。

1. NLM (Non-Local Means)

NLM是一种经典的图像降噪算法,其核心思想是:像素的颜色应该由其周围相似像素的颜色加权平均得到。

具体来说,对于图像中的每个像素,NLM算法会在其周围的搜索窗口内寻找相似的像素。两个像素的相似度由它们周围的邻域像素的差异来衡量。相似度越高,权重越大。

NLM的公式:

I'(i) = (1/C(i)) * Σ(j∈N(i)) w(i, j) * I(j)

其中:

  • I'(i) 是像素 i 降噪后的颜色。
  • I(j) 是像素 j 的颜色。
  • N(i) 是像素 i 周围的搜索窗口。
  • w(i, j) 是像素 i 和像素 j 的权重,由它们的相似度决定。
  • C(i) 是归一化因子,保证权重之和为1。

权重 w(i, j) 的计算:

w(i, j) = exp(-||P(i) - P(j)||^2 / (2 * h^2))

其中:

  • P(i)P(j) 分别是像素 i 和像素 j 周围的邻域像素的颜色向量。
  • ||P(i) - P(j)||^2 是邻域像素颜色向量的欧氏距离的平方。
  • h 是一个控制降噪强度的参数。

NLM的优缺点:

  • 优点: 降噪效果好,能保留图像的细节。
  • 缺点: 计算量大,速度慢。

2. SVGF (Spatiotemporal Variance-Guided Filtering)

SVGF是一种专门为光线追踪设计的降噪算法。它利用了光线追踪中的时域信息(历史帧的信息)和空域信息(当前帧的信息),以及方差信息(像素颜色的不确定性),来提高降噪效果和速度。

SVGF的核心思想是:根据像素颜色的方差,自适应地调整滤波的强度。

具体来说,SVGF算法包括以下几个步骤:

  1. 计算像素颜色的方差。
  2. 进行空域滤波。
  3. 进行时域滤波。
  4. 合并空域和时域滤波的结果。

SVGF的优势在于:

  • 利用了时域信息,可以更有效地减少噪声。
  • 根据方差自适应地调整滤波强度,可以更好地保留图像的细节。
  • 针对光线追踪进行了优化,速度较快。

SVGF的流程:

步骤 描述
方差估计 通过计算当前帧和历史帧像素颜色的差异,估计像素颜色的方差。方差越大,说明像素颜色的不确定性越高,需要更强的滤波。
空域滤波 对当前帧的像素颜色进行滤波。可以使用各种空域滤波器,比如高斯滤波器、双边滤波器等。SVGF通常使用改进的双边滤波器,根据像素颜色的方差来调整滤波的权重。
时域滤波 将当前帧的像素颜色与历史帧的像素颜色进行混合。混合的权重由像素颜色的方差和运动矢量决定。如果像素颜色的方差较小,且运动矢量较小,则可以更多地依赖历史帧的信息。
合并 将空域滤波和时域滤波的结果进行合并。可以使用加权平均或其他更复杂的合并方法。

WebGPU中的降噪算法

我们可以利用WebGPU的Compute Shader来实现NLM和SVGF算法。

NLM的WebGPU实现(伪代码):

// compute shader
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) global_id: vec3u) {
    let resolution = textureDimensions(inputTexture);
    let uv = vec2f(f32(global_id.x) / f32(resolution.x), f32(global_id.y) / f32(resolution.y));

    let searchRadius = 5; // 搜索半径
    let filterRadius = 2; // 邻域半径
    let h = 0.1; // 降噪强度参数

    var finalColor = vec4f(0.0);
    var totalWeight = 0.0;

    for (var i = -searchRadius; i <= searchRadius; i = i + 1) {
        for (var j = -searchRadius; j <= searchRadius; j = j + 1) {
            let neighborUV = uv + vec2f(f32(i) / f32(resolution.x), f32(j) / f32(resolution.y));
            if (neighborUV.x < 0.0 || neighborUV.x > 1.0 || neighborUV.y < 0.0 || neighborUV.y > 1.0) {
                continue; // 跳过越界像素
            }

            let neighborColor = textureSampleLevel(inputTexture, mySampler, neighborUV, 0.0);

            var similarity = 0.0;
            for (var k = -filterRadius; k <= filterRadius; k = k + 1) {
                for (var l = -filterRadius; l <= filterRadius; l = l + 1) {
                    let offsetUV = vec2f(f32(k) / f32(resolution.x), f32(l) / f32(resolution.y));
                    let uv1 = uv + offsetUV;
                    let uv2 = neighborUV + offsetUV;

                    if (uv1.x < 0.0 || uv1.x > 1.0 || uv1.y < 0.0 || uv1.y > 1.0 ||
                        uv2.x < 0.0 || uv2.x > 1.0 || uv2.y < 0.0 || uv2.y > 1.0) {
                        continue;
                    }

                    let color1 = textureSampleLevel(inputTexture, mySampler, uv1, 0.0).rgb;
                    let color2 = textureSampleLevel(inputTexture, mySampler, uv2, 0.0).rgb;
                    similarity = similarity + dot(color1 - color2, color1 - color2);
                }
            }

            let weight = exp(-similarity / (2.0 * h * h));
            finalColor = finalColor + weight * neighborColor;
            totalWeight = totalWeight + weight;
        }
    }

    let normalizedColor = finalColor / totalWeight;
    textureStore(outputTexture, vec2i(global_id.xy), normalizedColor);
}

SVGF的WebGPU实现(伪代码):

// Compute Shader (SVGF - simplified)
@group(0) @binding(0) var inputTexture : texture_2d<f32>;
@group(0) @binding(1) var velocityTexture : texture_2d<f32>; // 存储运动矢量
@group(0) @binding(2) var outputTexture : texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var<uniform> frameIndex : u32;

@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) global_id : vec3u) {
    let resolution = textureDimensions(inputTexture);
    let uv = vec2f(f32(global_id.x) / f32(resolution.x), f32(global_id.y) / f32(resolution.y));

    let currentColor = textureSample(inputTexture, mySampler, uv).rgb;

    // 1. Variance Estimation (Simplified - using temporal accumulation)
    let historyColor = textureSample(outputTexture, mySampler, uv).rgb; // 从上一帧的输出作为历史
    let alpha = 0.1; // Accumulation rate
    let accumulatedColor = mix(currentColor, historyColor, alpha);

    // 2. Spatial Filtering (Bilateral-like)
    let spatialRadius = 2;
    var spatialSum = vec3f(0.0);
    var spatialWeightSum = 0.0;

    for (var x = -spatialRadius; x <= spatialRadius; x = x + 1) {
        for (var y = -spatialRadius; y <= spatialRadius; y = y + 1) {
            let offsetUV = uv + vec2f(f32(x) / f32(resolution.x), f32(y) / f32(resolution.y));
            if (offsetUV.x < 0.0 || offsetUV.x > 1.0 || offsetUV.y < 0.0 || offsetUV.y > 1.0) {
                continue;
            }

            let neighborColor = textureSample(inputTexture, mySampler, offsetUV).rgb;

            // Weight based on color difference (Bilateral-like)
            let colorDiff = distance(currentColor, neighborColor);
            let spatialWeight = exp(-colorDiff * 10.0); // Adjust exponent for strength

            spatialSum += neighborColor * spatialWeight;
            spatialWeightSum += spatialWeight;
        }
    }

    let spatialFilteredColor = spatialSum / spatialWeightSum;

    // 3. Temporal Filtering (using Velocity buffer)
    let velocity = textureSample(velocityTexture, mySampler, uv).xy; // 从VelocityBuffer读取运动矢量
    let previousUV = uv - velocity; // Project to previous frame

    var temporalFilteredColor = spatialFilteredColor; // Default if out of bounds

    if (previousUV.x >= 0.0 && previousUV.x <= 1.0 && previousUV.y >= 0.0 && previousUV.y <= 1.0) {
        let previousColor = textureSample(outputTexture, mySampler, previousUV).rgb;
        temporalFilteredColor = mix(spatialFilteredColor, previousColor, 0.5); // Blend with history
    }

    textureStore(outputTexture, vec2i(global_id.xy), vec4f(temporalFilteredColor, 1.0));
}

表格总结

特性 NLM (Non-Local Means) SVGF (Spatiotemporal Variance-Guided Filtering)
核心思想 像素颜色由其周围相似像素的颜色加权平均得到。 根据像素颜色的方差,自适应地调整滤波的强度,并利用时域信息。
时域信息 不使用 使用 (历史帧的信息)
方差信息 不使用 使用 (像素颜色的不确定性)
计算复杂度 中等
降噪效果 好,能保留细节 优秀,尤其在处理动态场景时
适用场景 静态场景,对细节要求高的场景 动态场景,需要实时降噪的场景
WebGPU实现难度 中等 较高

总结

今天我们简单地介绍了WebGPU、Ray Tracing和降噪算法(NLM和SVGF)。希望大家对这些技术有了一个初步的了解。要掌握这些技术,还需要大量的实践和学习。记住,冰冻三尺非一日之寒,罗马也不是一天建成的!

光线追踪和降噪算法是一个庞大的领域,还有很多内容值得我们深入研究。比如,如何优化光线追踪的性能,如何设计更高效的降噪算法等等。希望大家能够继续探索,不断进步!

最后,祝大家编程愉快,Bug少少,头发多多!散会!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注