首页
/ three.js EnvMapCDFGenerator 源码解析:HDR 环境贴图边际/条件 CDF 预计算与亮度重要性采样

three.js EnvMapCDFGenerator 源码解析:HDR 环境贴图边际/条件 CDF 预计算与亮度重要性采样

2026-09-06 18:35:48作者:魏侃纯Zoe

导读

EnvMapCDFGenerator 是 three.js TSL(Three Shading Language)屏幕空间反射系统中负责环境光预计算的核心类:它从一张等距柱状投影(equirectangular)HDR 环境贴图中提取亮度分布,预生成边际(marginal)与条件(conditional)CDF 查表纹理,供 GPU 端进行亮度重要性采样与多重重要性采样(MIS)。本文将结合其唯一实现文件 examples/jsm/tsl/display/ImportanceSampledEnvironment.js 中约 170 行的核心算法,从类接口、预处理流程、CDF 反演数学到下游消费场景,完整还原这一类的工作机制,并给出在 WebGPU SSR 后处理中的实际接入方式。

一、为什么需要 CDF 预计算:屏幕空间反射中的"环境 miss"问题

在 three.js 的 TSL 屏幕空间反射方案(SSRNode)中,反射光线若未命中屏幕空间几何体,会回退到"环境 miss"估计——即用环境贴图作为远处光源的反射来源(见 examples/jsm/tsl/display/SSRNode.jssetEnvMap 的注释)。若直接用均匀方向采样环境贴图,采样会大量落在暗部区域,而 HDR 环境中亮度集中在极少数高亮区域,导致高方差、严重的噪点。

EnvMapCDFGenerator 正是为解决这一噪声问题而存在。根据 ImportanceSampledEnvironment.js 中对该类的描述,它:

Precomputes marginal and conditional CDF textures from an equirectangular HDR environment map for luminance importance sampling.

即:在 CPU 端将 HDR 环境图的亮度分布预先整理为两套一维/二维 CDF 查表纹理,运行时 GPU 只需几次纹理采样即可把随机数映射为"大概率落在亮区"的方向。该算法改编自 three-gpu-pathtracer 的 CDF 预计算与 MIS 环境 miss 估计思路(源码文件头注释有明确出处说明)。

二、类接口速览:构造函数、状态字段与公开方法

EnvMapCDFGeneratorImportanceSampledEnvironment 的内部辅助类,构造函数无参数,见 ImportanceSampledEnvironment.js

class EnvMapCDFGenerator {

    constructor() {

        this.map = null;              // 预处理后的 HDR 环境贴图(半浮点)
        this.marginalWeights = null;  // 边际 CDF 反演查表纹理(height × 1)
        this.conditionalWeights = null; // 条件 CDF 反演查表纹理(width × height)
        this.totalSum = 0;            // 整张图所有像素亮度之和

    }
}

四个公开状态的含义:

字段 类型 含义
map DataTexture(半浮点) 经过预处理、统一为 HalfFloat + RGBA 布局的环境贴图,保留原始颜色供最终采样
marginalWeights DataTexture(height × 1) 行级(纬度方向)边际 CDF 的"反演查表",用于先从随机数得到采样行
conditionalWeights DataTexture(width × height) 每一行内部的列级条件 CDF 反演查表,用于在该行内确定列
totalSum number 全图亮度总和,供 GPU 端将颜色换算为 PDF 使用

类上共有三个公开方法,构成完整的生命周期:

  • updateFrom( hdr ):一次完成"环境贴图预处理 + CDF 预计算",产出上述全部数据;
  • updateMapOnly( hdr ):只做环境贴图预处理(不生成 CDF 纹理),供关闭重要性采样时复用;
  • dispose():释放内部持有的三张纹理并清空引用。

三、updateFrom 核心算法:从亮度累加到 CDF 反演表

updateFrom 的完整流程在 ImportanceSampledEnvironment.js。它首先调用 updateMapOnly 拿到预处理贴图,随后按像素行逐行扫描计算权重。整个算法可分为四个阶段。

3.1 亮度权重:Rec.709 感知亮度

每个像素的采样权重不是 RGB 本身,而是亮度。权重函数定义在该文件顶部(ImportanceSampledEnvironment.js):

function colorToLuminance( r, g, b ) {

    return 0.2126 * r + 0.7152 * g + 0.0722 * b;

}

三个系数对应 ITU-R BT.709 标准亮度公式,描述人眼对红、绿、蓝通道的敏感度差异(绿色贡献最大)。读取像素时统一调用 DataUtils.fromHalfFloat 把半浮点还原为浮点参与计算。

3.2 条件(行内)PDF/CDF 构造

对每一行 y,从左到右扫描:

let cumulativeRowWeight = 0.0;
for ( let x = 0; x < width; x ++ ) {
    // 计算该像素亮度 weight
    cumulativeRowWeight += weight;
    totalSumValue += weight;
    pdfConditional[ i ] = weight;
    cdfConditional[ i ] = cumulativeRowWeight;
}

cdfConditional[i] 是该行内到当前位置为止的亮度前缀和。行扫完后,若行亮度总和非零,则整行归一化:

pdfConditional[ i ] /= cumulativeRowWeight;
cdfConditional[ i ] /= cumulativeRowWeight;

归一化后,pdfConditional 成为"该行内部按亮度的离散概率分布",cdfConditional 成为累积分布函数(终值为 1)。注意此处的归一化只针对行内,因此称为"条件"(给定行 y,在该行内随机取一列)。

3.3 边际(跨行)PDF/CDF 构造

与此同时,每一行的亮度总和被累计进一维数组:

cumulativeWeightMarginal += cumulativeRowWeight;
pdfMarginal[ y ] = cumulativeRowWeight;
cdfMarginal[ y ] = cumulativeWeightMarginal;

最后同样整体除以 cumulativeWeightMarginal 归一化。cdfMarginal 给出"按行的边际分布":给定一个全局随机数,可先据此选出一个采样行。整张图的 totalSumValue 也在第一层循环内同步累加完成,最终存入 this.totalSum

3.4 CDF 反演:把累积分布"翻转"成可直接采样的查表

若直接存储 CDF 数组,GPU 上做重要性采样仍需逐项比较(反演)。这里的做法是预先把反演结果存成纹理:对每个均匀分布的离散阈值,用二分查找定位它所对应的行/列索引。

用于查找的辅助函数 binarySearchFindClosestIndexOf[offset, offset+count) 区间内返回第一个不小于 targetValue 的索引(并剪掉区间偏移)。

生成边际表(一维,长度 height):

for ( let i = 0; i < height; i ++ ) {
    const dist = ( i + 1 ) / height;          // 均匀阈值
    const row = binarySearchFindClosestIndexOf( cdfMarginal, dist );
    marginalDataArray[ i ] = DataUtils.toHalfFloat( ( row + 0.5 ) / height );
}

生成条件表(二维,width × height):

for ( let y = 0; y < height; y ++ ) {
    for ( let x = 0; x < width; x ++ ) {
        const dist = ( x + 1 ) / width;       // 当前行内的均匀阈值
        const col = binarySearchFindClosestIndexOf( cdfConditional, dist, y * width, width );
        conditionalDataArray[ i ] = DataUtils.toHalfFloat( ( col + 0.5 ) / width );
    }
}

需要强调两点语义:

  1. 纹理里存的不是 CDF 值,而是"行/列中心坐标"(row+0.5)/height(col+0.5)/width)。+0.5 是把整数索引换算到纹素中心的 UV 坐标。
  2. 阈值采用等间距离散分布(i+1)/N),配合 GPU 端的线性过滤,均匀随机数经过该表即可平滑插值出近似连续的反 CDF 采样方向。

3.5 输出纹理的格式配置

预计算结果装配成两张 DataTexture,配置完全一致(ImportanceSampledEnvironment.js):

纹理 尺寸 关键参数
marginalWeights width = height, height = 1 type = HalfFloatTypeformat = RedFormatmin/magFilter = LinearFilterwrapS/T = ClampToEdgeWrappinggenerateMipmaps = false
conditionalWeights width × height 同上

RedFormat + HalfFloatType 表示每像素只存一个 16 位半浮点分量,内存紧凑;LinearFilter 保证运行时用连续随机数采样能得到平滑的行/列值;ClampToEdgeWrapping 防止边缘越界回绕;一维的边际表在 GPU 端以 height×1 的二维纹理承载,wrapT 自然无影响。

四、环境贴图预处理:updateMapOnlypreprocessEnvMap

CDF 计算要求输入数据形态统一(CPU 可读、半浮点、无 flipY、RGBA 布局),这一职责由 updateMapOnlyImportanceSampledEnvironment.js)→ 全局函数 preprocessEnvMapImportanceSampledEnvironment.js)完成:

  1. 克隆并复制 image 数据map = envMap.clone(),同时把 map.source 重建为携带原图数据的 TextureSource,避免浅拷贝引用导致后续数据被覆写。
  2. 统一为 HalfFloat:若原纹理不是 HalfFloatType,则分配 Uint16Array 逐像素转换。整数类型的输入(如 Uint8Array)会按 2^(8·BYTES_PER_ELEMENT)-1(有符号型取半值减一)归一化到 [0,1] 浮点后,再统一经 DataUtils.toHalfFloat 写入半浮点;若原类型就是 FloatType/HalfFloatType 则直接转换。
  3. 校正 flipY:若原贴图设置了 flipY,按行镜像重排 RGBA 像素后把 flipY 置为 false,保证后续按行扫描的物理方向与经纬度约定一致。
  4. 设置环绕方式:预处理贴图 wrapS = RepeatWrapping(水平经度方向可无缝环绕)、wrapT = ClampToEdgeWrapping(垂直纬度方向两端夹取)。同时 updateMapOnly 会把旧的 this.map dispose() 掉并把 totalSum 清零,避免脏数据残留。

这一步也是判断"输入是否合法"的关口:SSRNode 在 setEnvMap 中会先检查 hdr.image.data 是否存在(见 SSRNode.js),否则打印警告并拒绝——它要求的是带 CPU 端图像数据、可逐像素读取的 equirectangular HDR 纹理(如 RGBELoader/HDRLoader 产物),PMREM cubemap 与 scene.environment 不适用,因为后者已经历预处理且数据不可直接访问。

五、生命周期管理:dispose

由于该生成器在 CPU 端持有环境贴图与两张权重纹理,均为 GPU 显存对象,类提供了显式释放接口(ImportanceSampledEnvironment.js):

  • 依次对 marginalWeightsconditionalWeightsmap 调用 .dispose() 并置为 null
  • updateFrom 每次重算前也会先释放上一轮的边际/条件纹理(见 ImportanceSampledEnvironment.js),避免反复切换环境贴图时显存泄漏。

消费端 ImportanceSampledEnvironment.dispose() 直接委托给内部 _cdf.dispose();而 SSRNode.setEnvMap(null) 则走 clear()(新建生成器 + 重建材质)来整体关停环境 miss 采样(SSRNode.js)。

六、下游消费:如何在 GPU 端利用这些纹理做重要性采样

EnvMapCDFGenerator 通常由 ImportanceSampledEnvironment 持有(ImportanceSampledEnvironment.js),其 updateFrom( hdr )_importanceSampling 开关分派:

  • 开启时调用 _cdf.updateFrom(hdr),并把 totalSum 写入 float uniform _totalSum
  • 关闭时仅调用 _cdf.updateMapOnly(hdr),只保留环境贴图用于普通反射采样;
  • 两套权重纹理经 texture() 包装为 TSL 节点 _marginalNode/_conditionalNode,与 _mapNode 一起暴露给 shader。

GPU 端的反演采样流程位于 sampleEnvironmentMISImportanceSampledEnvironment.js):

const r_env = vec2( Xi2.z, Xi2.w );                       // 第二组蓝色噪声样本
const v_cdf = texture( marginalNode, vec2( r_env.x, float( 0 ) ) ).r;  // 先采样得行 v
const u_cdf = texture( conditionalNode, vec2( r_env.y, v_cdf ) ).r;    // 用 v 查该行内得列 u
const isEnvUV = vec2( u_cdf, v_cdf );                     // 合成环境贴图采样坐标

这一"先查边际表得行号、再把行号当条件表 v 坐标查列号"的两级间接寻址,正是 3.4 节两张查表纹理的设计目的——把二维离散分布的反演拆成了两个一维反演。得到的 isEnvUV 方向颜色与 PDF:

const sampledColor = texture( mapNode, isEnvUV ).level( 0 ).rgb;
const pdfEnv2 = envW.mul( envH )
    .mul( luminance( sampledColor ).div( totalSum ) )
    .mul( equirectDirPdf( envDirWS ) );

其中 envW*envH*N/(totalSum) 把"像素亮度占比"换算为连续的概率密度,equirectDirPdf 是在 examples/jsm/tsl/utils/SpecularHelpers.js 中定义的等距柱状参数化下的立体角 PDF 修正项 1/(2π²·sinθ)(在极点处以 0 保护避免除零)。totalSum 因此在"CPU 上累加亮度"与"GPU 上估算 PDF"之间构成关键纽带。

随后通过 misPowerHeuristic(pdfEnv, pdfBrdf)SpecularHelpers.js,即 β=2 的 pdfA²/(pdfA²+pdfB²) 幂启发式,出处注明为 Eric Veach 的《Optimally Combining Sampling Techniques for Monte Carlo Rendering》)把"BRDF 反射方向采样"与"环境亮度 CDF 采样"两种策略按 PDF 比例无偏融合。代码注释同时说明了一个重要的经验规则:对近似镜面的高光波瓣(alpha ≲ 0.01,即 roughness ≲ 0.1),跳过 CDF 二次采样——因为全局 CDF 方向几乎不可能落进如此狭窄的镜面波瓣内,徒增开销(见 ImportanceSampledEnvironment.js)。

七、实战接入:在 WebGPU SSR 后处理中开启环境重要性采样

生产示例见 examples/webgpu_postprocessing_ssr_denoise.html,其接入要点为:

  1. HDRLoader(示例配置路径 textures/equirectangular/)加载 HDR 环境图,得到带 CPU 数据的 equirectangular 纹理;
  2. 配置 SSR 参数(该示例中 envImportanceSampling 默认 false),据此构造 SSRNode
    this._importanceEnvironment = new ImportanceSampledEnvironment( this.envImportanceSampling );
    
    (见 SSRNode.js
  3. 场景环境图变化时调用 ssrNode.setEnvMap( hdrTexture ),内部完成 updateFrom → 预计算 CDF → _buildSSRMaterial() 重建材质;
  4. 传入 null 可关停;反射强度可用 ssrNode.envMapIntensity(浮点 uniform,默认 1.0)调节。

接入的限制条件必须在工程中明确:输入必须是 equirectangular 且 CPU 可读(含 .image.data)的 HDR 纹理,RGBELoader/HDRLoader 加载的结果满足要求;PMREMGenerator 烘焙的 cubemap 与 scene.environment 由于数据形态不符,会被 setEnvMap 显式拒绝。

八、相关文件速查

文件 与本类的关系
ImportanceSampledEnvironment.js EnvMapCDFGenerator 的定义与唯一来源;同文件内还有消费它的 ImportanceSampledEnvironment
SpecularHelpers.js 提供 equirectDirPdfmisPowerHeuristicD_GTRF_SchlickSmithGGeometryTerm 等 GPU 端公式
SSRNode.js 集成方:setEnvMap / envImportanceSampling / envMapIntensity,负责环境 miss 采样调度
webgpu_postprocessing_ssr_denoise.html 可运行示例:HDR 加载、SSR 参数面板与 setEnvMap 调用

若想自行验证整条链路,建议按如下步骤操作:

  1. 打开 webgpu_postprocessing_ssr_denoise.html,确认浏览器支持 WebGPU;
  2. 在 UI 中开启 SSR 相关的 envImportanceSampling 选项并切换不同 HDR 环境,观察镜面反射噪点在高动态范围环境下的差异;
  3. updateFrom 为入口单步跟踪 ImportanceSampledEnvironment.js,核对边际/条件 CDF 数组与两张输出纹理的对应关系。

综上,EnvMapCDFGenerator 用约 120 行 CPU 代码把一个"按亮度分布采样 HDR 环境"的统计问题,转化为两张小巧的半浮点查表纹理与一次 GPU 双级纹理采样,为 TSL 屏幕空间反射在 HDR 场景下的低噪渲染提供了坚实的概率基础。理解它的边际/条件分解与反演查表思想,不仅有助于调试 SSR 反射质量,也能为其他需要环境重要性采样的渲染功能提供可直接复用的设计范式。

登录后查看全文
热门项目推荐
相关项目推荐