04 / 08 · RELIGHTING
Send light through the splat pipeline让反射光走完 splat 管线
Follow two GPU passes and their color spaces.走通两个 GPU pass 与颜色空间。
Shade the surfel, project the footprint着色 surfel,投影覆盖区域
The asset has 1,536 thin Gaussian ellipsoids on an analytic sphere. Each stores a center, three covariance axes, an outward normal and linear base color. Roughness and metallic are uniform across this object. WGSL shades each center, holding that radiance constant across its footprint.资产由解析球面上的 1,536 个薄高斯椭球组成。每个有中心、三个协方差轴、朝外法线和线性 base color。粗糙度与金属度在整个物体上统一。WGSL 在中心着色,辐亮度在该点的覆盖区域内保持不变。
This approximates a surface using 3D Gaussian footprints; it is not the perspective-correct 2DGS algorithm. CPU code transforms and sorts centers far-to-near. WGSL projects covariance, expands a three-sigma quad and evaluates Gaussian weight. Overlap makes it nearly opaque, with soft silhouettes.这里用 3D Gaussian 足迹近似表面,并不是透视正确的 2DGS 算法。CPU 变换中心并由远及近排序;WGSL 投影协方差,展开三倍标准差的四边形,求高斯权重。叠加使它接近不透明,但轮廓柔软。
Add linear light, then tone-map先在线性空间合光,再做色调映射
Fragments write (αL,α) into RGBA16Float. In far-to-near order, premultiplied blending computes new=αL+(1−α)old. Because α is already included, the source blend factor is One. Using SourceAlpha again would incorrectly square α.片元向 RGBA16Float 写入 (αL,α)。由远及近绘制时,预乘透明合成是 new=αL+(1−α)old。由于已经乘过 α,源混合因子是 One;再次用 SourceAlpha 会错误地把 α 平方。
A fullscreen pass adds visible background, applies per-channel Reinhard x/(1+x), then encodes to sRGB once. sRGB attachments encode automatically; a UNORM canvas needs explicit encoding. Normal/base-color debug modes skip Reinhard.全屏 pass 加入未被遮住的背景,逐通道应用 Reinhard x/(1+x),最后只编码一次 sRGB。sRGB 附件自动编码,UNORM 画布需显式编码。法线与 base-color 调试模式跳过 Reinhard。
What is and is not implemented哪些已经实现
The convex shape has one distant white light. There is no cast-shadow pass, environment map, indirect light, inverse-material estimator or training optimizer. Backface rejection is not a general light-visibility solution. Concave scenes need visibility data or ray/shadow queries.凸形状受一盏远处白色平行光照射。没有投射阴影 pass、环境贴图、间接光、逆材质估计器或训练优化器。背面剔除不能替代一般光照可见性求解。凹陷场景需要可见性数据或光线/阴影查询。
Native offscreen checks verify light/material response, roughness, deformation and camera changes. Windows/DX12 and browser behavior are tested here; sharing WGSL does not certify untested Metal/Vulkan devices.原生离屏检查验证光照/材质响应、粗糙度、形变与相机变化。这里测试 Windows/DX12 和浏览器行为;共享 WGSL 不代表已验证未测试的 Metal/Vulkan 设备。
Predict it, then run it先预测,再运行
Open this experiment →打开对应实验 →The formula meets actual code公式连接真实代码
This excerpt is extracted from the repository during the site build. SH environment lighting and inverse training are future extensions; their chapters point to the current direct-light/data boundary, not an implemented trainer.此片段在网站构建时从仓库提取。SH 环境光照与逆向训练是未来扩展;对应章节指向当前直射光/数据接口,不代表已实现训练器。
src/relight.rs · L298–397
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
size: [u32; 2],
frame: RelightFrame,
) -> Result<(), String> {
frame.validate()?;
if size.contains(&0)
|| size
.iter()
.any(|&v| v > device.limits().max_texture_dimension_2d)
{
return Err("Invalid relight target size".into());
}
if self.hdr.as_ref().is_none_or(|v| v.2 != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("linear-radiance"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba16Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.hdr_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
}],
});
self.hdr = Some((view, bind, size));
}
let view = Mat4::look_at_rh(frame.eye(), Vec3::ZERO, Vec3::Y);
let projection = Mat4::perspective_rh(
45_f32.to_radians(),
size[0] as f32 / size[1] as f32,
0.05,
50.0,
);
queue.write_buffer(
&self.globals,
0,
bytemuck::bytes_of(&Globals {
vp: (projection * view).to_cols_array_2d(),
eye: frame.eye().extend(0.0).to_array(),
light: frame.light().extend(frame.intensity).to_array(),
material: [frame.roughness, frame.metallic, frame.mode as f32, 0.0],
viewport: [
size[0] as f32,
size[1] as f32,
if self.srgb { 1.0 } else { 0.0 },
0.0,
],
}),
);
queue.write_buffer(&self.splats, 0, bytemuck::cast_slice(&scene(frame, view)));
let (hdr, texture_binding, _) = self.hdr.as_ref().unwrap();
for (view, pipeline, display) in [
(hdr, &self.splat_pipeline, false),
(target, &self.display_pipeline, true),
] {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("relight-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.bindings, &[]);
if display {
pass.set_bind_group(1, texture_binding, &[]);
pass.draw(0..3, 0..1);
} else {
pass.draw(0..6, 0..COUNT as u32);
}
}
Ok(())
}Check your understanding检查是否真正理解
Why not tone-map every Gaussian before blending?为什么不先对每颗 Gaussian 做色调映射再合成?
Reveal the reasoning展开推理
Tone mapping is nonlinear: T(αa+(1−α)b) generally differs from αT(a)+(1−α)T(b). Add light in linear HDR first.色调映射是非线性的,T(αa+(1−α)b) 通常不等于 αT(a)+(1−α)T(b)。应先在线性 HDR 中合光。