各位观众老爷,晚上好!今天咱们来聊聊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的基本步骤:
- 发射光线 (Ray Generation):从摄像机位置,为每个像素发射一条或多条光线。
- 相交测试 (Intersection Test):判断光线与场景中的物体是否相交。
- 着色 (Shading):如果光线与物体相交,则根据光照模型计算交点处的颜色。这通常包括计算直接光照、反射光照、折射光照等。
- 递归追踪 (Recursive Tracing):对于反射和折射光线,继续递归地进行光线追踪,直到达到最大追踪深度或光线能量衰减到一定程度。
WebGPU中的Ray Tracing
WebGPU本身并没有直接提供光线追踪API,但我们可以利用WebGPU的Compute Shader来实现光线追踪。思路是:
- 在Compute Shader中模拟光线追踪算法。
- 将渲染结果写入纹理。
- 在渲染管线中将纹理绘制到屏幕上。
下面是一个简单的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算法包括以下几个步骤:
- 计算像素颜色的方差。
- 进行空域滤波。
- 进行时域滤波。
- 合并空域和时域滤波的结果。
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少少,头发多多!散会!