three.js WebGPUTimestampQueryPool 详解:WebGPU 渲染耗时查询池的原理与实战
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
WebGPUTimestampQueryPool 是 three.js 在 WebGPU 后端中负责"性能计时"的核心工具类,它通过管理一组 WebGPU 时间戳查询(timestamp query)资源,在 GPU 上精确测量每个渲染/计算通道(render/compute pass)的执行耗时。本文以其官方 API 文档 docs/pages/WebGPUTimestampQueryPool.html.md 为主线,结合 WebGPUTimestampQueryPool.js 源码、基类与后端集成实现,讲清它如何被懒加载创建、如何分配与回读时间戳、以及如何在自己的 WebGPU 渲染循环中读取毫秒级耗时数据。
WebGPUTimestampQueryPool 在 three.js 架构中的位置
从类的继承关系看,WebGPUTimestampQueryPool 继承自抽象基类 TimestampQueryPool(见 src/renderers/common/TimestampQueryPool.js),后者是所有渲染后端共用的"时间戳查询池"基类,它定义了池的基本状态与对外访问接口:
trackTimestamp:是否开启时间戳跟踪,默认true;maxQueries:池可容纳的最大查询数(基类默认256);currentQueryIndex:已分配查询的游标;queryOffsets:Map,记录每个渲染上下文 uid → 查询基础偏移;timestamps:Map,保存每个 uid 解析后的耗时结果;lastValue:最近一次解析得到的整帧总耗时;pendingResolve:用于避免并发解析的标志/待解析 Promise(WebGL 后端用作布尔值,WebGPU 后端存 Promise);getTimestampFrames()、getTimestamp(uid)、hasTimestampQuery(uid):供上层读取帧列表、单次查询耗时与可用性。
同时基类声明了三个抽象方法allocateQueriesForContext、resolveQueriesAsync、dispose,分别在 WebGPU 后端(WebGPUTimestampQueryPool.js)与 WebGL 后端(src/renderers/webgl-fallback/utils/WebGLTimestampQueryPool.js)中实现——这正是 three.js 用"公共抽象基类 + 后端专用实现"统一 WebGL/WebGPU 能力差异的典型设计。官方 API 文档将本类定位为"Extends the base TimestampQueryPool to provide WebGPU-specific implementation",与本仓库源码中的 JSDoc 注释完全一致。
从调用方看,WebGPUTimestampQueryPool 实例不会由用户手动new,而是由 WebGPU 后端在需要计时的第一个渲染帧内懒加载创建。在 src/renderers/webgpu/WebGPUBackend.js 的initTimestampQuery()中可以看到:
initTimestampQuery( type, uid, descriptor ) { if ( ! this.trackTimestamp ) return; if ( ! this.timestampQueryPool[ type ] ) { // TODO: Variable maxQueries? this.timestampQueryPool[ type ] = new WebGPUTimestampQueryPool( this.device, type, 2048 ); } const timestampQueryPool = this.timestampQueryPool[ type ]; const baseOffset = timestampQueryPool.allocateQueriesForContext( uid ); _renderPassTimestampWrites.querySet = timestampQueryPool.querySet; _renderPassTimestampWrites.beginningOfPassWriteIndex = baseOffset; _renderPassTimestampWrites.endOfPassWriteIndex = baseOffset + 1; descriptor.timestampWrites = _renderPassTimestampWrites; }后端在基类 src/renderers/common/Backend.js 中为render与compute两种类型各预留了一个池槽位:timestampQueryPool = { [TimestampQuery.RENDER]: null, [TimestampQuery.COMPUTE]: null }。在 src/constants.js 中TimestampQuery.RENDER与TimestampQuery.COMPUTE的值分别是字符串'render'与'compute',即构造函数中type参数的取值来源。
构造函数与 WebGPU 底层资源编排
官方文档给出的构造签名如下:
new WebGPUTimestampQueryPool( device, type, maxQueries )- device:用于创建查询资源的 WebGPU 设备(
GPUDevice); - type:查询池类型标识符(
'render'或'compute'),同时用于 GPU 资源的 label 命名; - maxQueries:池可容纳的最大查询数量,默认
2048。
构造函数的核心工作,是围绕"查询结果从 GPU 回到 CPU"的整条数据通路,一次性创建三块 GPU 资源。见 src/renderers/webgpu/utils/WebGPUTimestampQueryPool.js:
- querySet(GPUQuerySet,type 为
'timestamp'):GPU 端保存时间戳槽位的集合,count 等于maxQueries; - resolveBuffer(GPUBuffer,usage =
QUERY_RESOLVE | COPY_SRC):时间戳解析结果的暂存缓冲,大小maxQueries * 8字节(WebGPU 时间戳为 64 位/8 字节); - resultBuffer(GPUBuffer,usage =
COPY_DST | MAP_READ):用于把结果拷贝回 CPU 并可被mapAsync映射读取的最终缓冲,大小同为maxQueries * 8字节。
三块资源的 label 依次为queryset_global_timestamp_${type}、buffer_timestamp_resolve_${type}、buffer_timestamp_result_${type},便于在浏览器 WebGPU 调试工具中辨识。源码中使用了仓库自定义的资源描述符类GPUBufferDescriptor、GPUQuerySetDescriptor、GPUCommandEncoderDescriptor(位于 src/renderers/webgpu/descriptors)以便复用与内存池化,每次创建后立即reset()归还描述符对象。
值得留意的是:当前构造函数创建的是单个 timestamp querySet,这正是本类与 WebGL 版本实现的重要差异来源——WebGPU 通过渲染/计算通道描述符里的timestampWrites字段(beginningOfPassWriteIndex / endOfPassWriteIndex)把时间戳写入该 querySet 的指定槽位,而不需要像 WebGL 那样依赖EXT_disjoint_timer_query_webgl2扩展与显式beginQuery/endQuery调用。
核心方法逐一解析
.allocateQueriesForContext( uid : string ) : number
每次渲染一个场景或执行一次 compute 时,后端都会调用该方法为当前渲染上下文分配一对相邻槽位(起点索引与终点索引),返回基础偏移量。
完整实现见 src/renderers/webgpu/utils/WebGPUTimestampQueryPool.js:
allocateQueriesForContext( uid ) { if ( ! this.trackTimestamp || this.isDisposed ) return null; if ( this.currentQueryIndex + 2 > this.maxQueries ) { this.resolveQueriesAsync(); this.currentQueryIndex = 0; this.queryOffsets.clear(); } const baseOffset = this.currentQueryIndex; this.currentQueryIndex += 2; this.queryOffsets.set( uid, baseOffset ); return baseOffset; }其行为要点:
- 若
trackTimestamp为false或池已销毁(isDisposed),直接返回null,调用方据此跳过时间戳写入(官方文档所说 "Returns null if allocation failed"); - 每个上下文消耗 2 个槽位,因此分配前检查
currentQueryIndex + 2 > maxQueries:当槽位耗尽时会先触发一次异步解析(resolveQueriesAsync()),然后把游标与 uid→偏移映射清空,从而让单个池可以在长生命周期内无限复用; uid是每个渲染/计算上下文的唯一标识。结合后端 src/renderers/common/Backend.js 的updateTimeStampUID()可以看到其格式为前缀:id:f帧号(如render:12:f34),前缀c:对应 compute、其余对应 render,帧号由:f(\d+)$捕获——这一格式正是_resolveQueries中解析耗时归属帧的依据。
.resolveQueriesAsync() : Promise.
将所有已分配但尚未解析的查询异步解析,返回最后一帧的总耗时(毫秒)。该方法是整个池子数据回读的入口,官方文档明确指出它具备"去重"特性:若已存在一个未完成的解析操作,直接返回该 Promise(If there's already a pending resolve operation, returns that promise instead)。
见 resolveQueriesAsync 与其私有实现_resolveQueries(同文件第 132-252 行),完整流程如下:
async _resolveQueries() { if ( this.isDisposed ) return this.lastValue; if ( this.resultBuffer.mapState !== 'unmapped' ) return this.lastValue; const currentOffsets = new Map( this.queryOffsets ); const queryCount = this.currentQueryIndex; const bytesUsed = queryCount * 8; // Reset state before GPU work this.currentQueryIndex = 0; this.queryOffsets.clear(); const commandEncoder = this.device.createCommandEncoder( _commandEncoderDescriptor ); commandEncoder.resolveQuerySet( this.querySet, 0, queryCount, this.resolveBuffer, 0 ); commandEncoder.copyBufferToBuffer( this.resolveBuffer, 0, this.resultBuffer, 0, bytesUsed ); const commandBuffer = commandEncoder.finish(); submit( this.device, commandBuffer ); await this.resultBuffer.mapAsync( GPUMapMode.READ, 0, bytesUsed ); const times = new BigUint64Array( this.resultBuffer.getMappedRange( 0, bytesUsed ) ); const framesDuration = {}; const frames = []; this.timestamps.clear(); for ( const [ uid, baseOffset ] of currentOffsets ) { const match = uid.match( /^(.*):f(\d+)$/ ); const frame = parseInt( match[ 2 ] ); // ...按帧累加、解析每对起止时间戳的耗时... const startTime = times[ baseOffset ]; const endTime = times[ baseOffset + 1 ]; const duration = Number( endTime - startTime ) / 1e6; // 纳秒 → 毫秒 this.timestamps.set( uid, duration ); framesDuration[ frame ] += duration; } // Return the total duration of the last frame const totalDuration = framesDuration[ frames[ frames.length - 1 ] ]; this.resultBuffer.unmap(); this.lastValue = totalDuration; this.frames = frames; return totalDuration; }内部机制可以拆解为:
- 异步串行保护:进入时先检查
resultBuffer.mapState !== 'unmapped'——如果上一次映射尚未解除就提前返回上次的lastValue,避免 GPUBuffer 在mapAsync期间被再次提交命令; - 先重置、后做 GPU 工作:在真正提交解析命令之前就把游标与偏移表清空,这样解析过程中的新分配会进入下一轮批次,不会污染本次数据快照;
- GPU 侧两级缓冲:
resolveQuerySet()把 querySet 中0..queryCount的时间戳解析到resolveBuffer(QUERY_RESOLVE),再用copyBufferToBuffer()拷入resultBuffer(MAP_READ),最终以submit()提交命令缓冲区; - CPU 侧映射回读:
mapAsync( GPUMapMode.READ, 0, bytesUsed )后,用BigUint64Array包装映射区(64 位纳秒时间戳必须用 BigUint64Array 读取,这正是缓冲区大小按maxQueries * 8计算的原因); - 耗时换算与按帧归组:对每对
[baseOffset, baseOffset + 1]读取起点/终点时间戳,duration = Number( end - start ) / 1e6将纳秒换算为毫秒,写入timestamps;随后按 uid 中解析出的帧号把同一帧内所有子耗时累加,最终返回最后一帧的总耗时; - 失败兜底:整个流程包裹在 try/catch 中,任何异常都会调用
error()打印日志、尝试unmap并返回lastValue(与文档 "returns the last valid value if resolution fails" 对应)。
.dispose() : Promise
销毁查询池,释放全部 GPU 资源并清空 CPU 侧状态。官方文档注明它重写了基类的TimestampQueryPool#dispose且为异步方法(返回 Promise)。实现位于 src/renderers/webgpu/utils/WebGPUTimestampQueryPool.js:
- 幂等保护:若
isDisposed为true直接返回; - 等待未完成的解析操作:若
pendingResolve存在,先await其结束(确保不会在映射期间销毁缓冲); - 解除映射:若
resultBuffer仍处于mapped状态,先unmap()(WebGPU 规范禁止销毁处于映射态的缓冲); - 依次
destroy()并置空querySet、resolveBuffer、resultBuffer; - 清空
queryOffsets、timestamps、frames,并把pendingResolve置null。
该方法的调用方是后端析构链路:在 WebGPUBackend.js 的dispose()中遍历并 dispose 所有类型池;同时在 src/renderers/common/Renderer.js 的dispose()里也会对backend.timestampQueryPool中的每个池执行 dispose,保证渲染器销毁时不会泄漏 GPU 资源。
在渲染循环中的实际用法与调用链
WebGPUTimestampQueryPool 的全流程由三处协作完成:
- 写入阶段:渲染/计算每个 pass 前,
WebGPUBackend在 src/renderers/webgpu/WebGPUBackend.js(render 通道)与同文件第 1866 行(compute 通道)调用initTimestampQuery(...),把timestampWrites(begin/end 写索引)挂到 pass 描述符上; - 回读阶段:用户代码主动调用
WebGPURenderer.resolveTimestampsAsync( type )。该公共方法定义在 src/renderers/common/Backend.js:它从timestampQueryPool[ type ]取出池子,await queryPool.resolveQueriesAsync(),并把返回值写入renderer.info[ type ].timestamp; - 展示阶段:从 src/renderers/common/Info.js 可见
info.render.timestamp与info.compute.timestamp均初始化为 0,调用toFixed( 6 )即可显示毫秒耗时。
官方示例 examples/webgpu_storage_buffer.html 演示了最典型的用法:
renderer.compute( compute ); renderer.render( scene, camera ); renderer.resolveTimestampsAsync( THREE.TimestampQuery.COMPUTE ); renderer.resolveTimestampsAsync( THREE.TimestampQuery.RENDER ); timestamps[ forceWebGL ? 'webgl' : 'webgpu' ].innerHTML = ` Compute ${renderer.info.compute.frameCalls} pass in ${renderer.info.compute.timestamp.toFixed( 6 )}ms<br> Draw ${renderer.info.render.drawCalls} pass in ${renderer.info.render.timestamp.toFixed( 6 )}ms`;examples/webgpu_compute_reduce.html 则是 compute-only 场景:每次执行若干 compute pass 后调用一次resolveTimestampsAsync( THREE.TimestampQuery.COMPUTE ),再从renderer.info.compute.timestamp.toFixed( 6 )读取结果。
需要说明的适用前提与限制:
- 设备能力:时间戳查询依赖 WebGPU
timestamp-query特性。在 WebGPUTimestampQueryPool 源码中查询集以type: 'timestamp'创建,若设备不支持,createQuerySet会抛错;真实可用的设备能力上限通常还受maxTimestampQueryCount限制,因此 maxQueries 并非越大越好; - 耗时定义:返回值是最近一帧内本类型所有 render/compute pass 的总耗时(毫秒),且每对起止时间戳以整个 pass 的 begin/end 为界,属于 GPU 端的绝对时间差,不含 CPU 提交开销;
- 手动触发回读:时间戳解析必须由业务代码在渲染循环内主动调用
resolveTimestampsAsync()触发,这与 WebGL 后端在帧末自动解析的机制不同; - 可用类型:
type只接受 TimestampQuery 的'render'或'compute',后端还会依据 uid 前缀c:自动判断某次查询归属于哪个池子(见 src/renderers/common/Backend.js)。
小结
从本仓库源码可以归纳出 WebGPUTimestampQueryPool 的设计要点:它把"创建 GPU 时间戳资源 → 按上下文成对分配槽位 → 槽位耗尽自动轮转 → 两级缓冲回读 + 按帧归组 → 异常兜底与幂等销毁"整条链路收敛到一个类中;同时通过公共基类 TimestampQueryPool 与后端无关的 uid/querySet/偏移协议,让 render/compute 两条管线共用一个 2048 容量的池子且支持无限帧复用。对需要做 WebGPU 性能分析的开发者而言,只需理解initTimestampQuery(写)、resolveQueriesAsync(读)、info.timestamp(展示)这一调用链,即可获得精确到毫秒的每帧 GPU 耗时数据。
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考