首页
/ three.js CompressedArrayTexture 深度解析:压缩 2D 纹理数组的构造、属性与按层更新机制

three.js CompressedArrayTexture 深度解析:压缩 2D 纹理数组的构造、属性与按层更新机制

2026-09-06 15:32:36作者:何举烈Damon

本文基于 three.js 官方 API 文档 docs/pages/CompressedArrayTexture.html.md,完整覆盖 CompressedArrayTexture 的构造参数、属性与方法,并结合 CompressedArrayTexture.js 源码实现、WebGLTextures.js 中的 GPU 上传路径,以及官方示例 webgl_texture2darray_layerupdate.html,讲清楚这类纹理是如何从压缩数据进入 GPU、以及 addLayerUpdate 按层局部更新相比整幅重传为何更省性能。读完本文,你可以直接在项目中构造压缩纹理数组(如加载 KTX2 动画帧序列),并掌握低开销的逐层刷新实战技巧。

一、CompressedArrayTexture 是什么:继承体系与定位

官方文档将其定义为一类“基于压缩数据创建的 2D 纹理数组(texture 2D array)”,并说明这类纹理通常由 CompressedTextureLoader 加载创建。其继承链为:

EventDispatcher → Texture → CompressedTexture → CompressedArrayTexture

CompressedArrayTexture.js 中可以直接看到这一点:

class CompressedArrayTexture extends CompressedTexture {
	constructor( mipmaps, width, height, depth, format, type ) {

		super( mipmaps, width, height, format, type );
		// ...
	}
}

父类 CompressedTexture.js 决定了它的几个关键行为,理解这些对正确使用该类至关重要:

  • image 属性只描述尺寸:压缩纹理没有 DOM 图像,父类将其设为 this.image = { width, height }CompressedArrayTexture 在此基础上追加 this.image.depth = depth,因此 image 最终是 { width, height, depth } 三个纯数值字段,供渲染器判断纹理尺寸;
  • flipY 恒为 false:压缩数据无法在上传时做垂直翻转,父类显式覆写了该标志;
  • generateMipmaps 恒为 false:压缩格式不能由 GPU 自动生成 mipmap,mipmap 必须内嵌在纹理文件(如 KTX2)中,随 mipmaps 数组一并传入;
  • mipmaps 数组:保存所有 mipmap(包含 0 号基础级)的数据与尺寸,每个元素形如 { data, width, height }

单元测试 CompressedArrayTexture.tests.js 也验证了继承关系(instanceof CompressedTexture 为真)与 isCompressedArrayTexture 标志的默认值。

二、构造函数参数逐项说明

文档给出的签名与默认值如下(以 CompressedArrayTexture.js 第 24 行构造器为准):

new CompressedArrayTexture( mipmaps, width, height, depth, format, type )
参数 类型 默认值 含义
mipmaps Array<Object> 必填 所有 mipmap(含基础级)的数据与尺寸,每项包含 data 与各级的 width / height
width number 必填 纹理宽度
height number 必填 纹理高度
depth number 必填 纹理数组的层数(深度)
format number RGBAFormat 纹理像素格式,需与压缩数据实际格式一致(如 RGBAFormat 表示未压缩或透传格式)
type number UnsignedByteType 纹理数据类型

需要说明的一处细节:官方文档中对 formattype 的描述文字写的是 “The min filter value”,这与 CompressedArrayTexture.js 源码 JSDoc 中的措辞一致,属于上游文档的笔误;从参数名与父类 CompressedTexture.js 的传递逻辑看,二者实际语义分别是“纹理像素格式”与“纹理数据类型”,本文按此解释。

构造器在 super() 之后还做了三件事(见 CompressedArrayTexture.js):

this.isCompressedArrayTexture = true;   // 类型检测标志
this.image.depth = depth;              // image 追加深度维度
this.wrapR = ClampToEdgeWrapping;      // 深度方向包裹方式
this.layerUpdates = new Set();         // 待更新层注册表

三、Properties:.image / .isCompressedArrayTexture / .layerUpdates / .wrapR

.image : Object

文档指出:“压缩纹理的 image 属性只定义其尺寸”。由于父类 CompressedTexture 已把 image 覆写为 { width, height }CompressedArrayTexture 再补上 depth。因此对这类纹理而言,image 不是图像数据,而是一个纯尺寸描述对象,渲染器用它来确定 texStorage3D / texImage3D 的体尺寸参数。

.isCompressedArrayTexture : boolean (readonly)

只读类型标志,默认为 true,用于类型测试(instanceof 之外的 duck-typing 判断)。three.js 渲染管线内部大量使用此类标志区分上传路径,例如 WebGLTextures.js 中根据 texture.isCompressedArrayTexture 决定 GPU 纹理目标为 gl.TEXTURE_2D_ARRAY

.layerUpdates : Set

一个 Set,记录当前需要向 GPU 重新上传的纹理层索引。默认空集。这是 addLayerUpdate 机制的底层数据结构,在上传完成后由渲染器自动清空(详见下节原理分析)。

.wrapR : RepeatWrapping | ClampToEdgeWrapping | MirroredRepeatWrapping

定义纹理在深度方向的包裹方式,对应 UVW 映射中的 W 分量,默认为 ClampToEdgeWrapping。采样 2D 纹理数组时使用 sampler2DArray 并以 vec3(uv, layerIndex) 采样,wrapR 决定 W 分量(即层坐标)越界时的行为。

另外,CompressedArrayTexture.js 中的 copy( source ) 方法在调用 super.copy 后会额外拷贝 wrapR,因此用 texture.copy() 复制此类纹理时深度包裹方式不会丢失。

四、Methods:addLayerUpdate 与 clearLayerUpdates

.addLayerUpdate( layerIndex : number )

文档原文的解释是:通常把 needsUpdate 设为 true 时,整个压缩纹理数组会被发送到 GPU;而标记具体层后,只会传输与某个深度关联的所有 mipmap 子集,这通常高效得多。实现极简:

addLayerUpdate( layerIndex ) {
	this.layerUpdates.add( layerIndex );
}

.clearLayerUpdates()

重置层更新注册表(this.layerUpdates.clear())。一般不需要手动调用——渲染器在完成一次按层上传后会自动清理,手动调用适用于“取消尚未渲染的更新标记”这类边缘场景。

五、源码级原理:layerUpdates 如何节省 GPU 传输

理解 addLayerUpdate 的价值,必须看 WebGLTextures.jsuploadTextureisCompressedArrayTexture 的处理分支:

  1. 首次上传(分配显存):当 useTexStorage && allocateMemory 时,先调用 state.texStorage3D( gl.TEXTURE_2D_ARRAY, levels, glInternalFormat, mipmaps[0].width, mipmaps[0].height, image.depth ) 一次性分配整个数组的显存;
  2. 逐 mipmap 上传:遍历 mipmaps,对非 RGBAFormat 的压缩数据调用 compressedTexSubImage3D
    • layerUpdates.size > 0,则计算单层字节数 layerByteLength = getByteLength( mipmap.width, mipmap.height, texture.format, texture.type ),对 layerUpdates 中的每个 layerIndexmipmap.datasubarray 出该层数据,再以 (x=0, y=0, layerIndex, w, h, depthSize=1) 为区域调用 compressedTexSubImage3D——每一级 mipmap 只上传被标记的那一层
    • 若无层标记,则以 image.depth 为深度一次性上传整幅数组(compressedTexSubImage3D( ..., 0, 0, image.depth, glFormat, mipmap.data ));
  3. 自动清理:上传循环结束后执行 if ( texture.layerUpdates.size > 0 ) texture.clearLayerUpdates();,保证标记不重复生效。

也就是说:一次动画帧只改了 3 个层中的 1 个时,整幅重传的数据量与层数成正比(depth × 每级 mipmap 大小),而按层更新只传输 1 × 每级 mipmap 单层大小,层数越多节省越明显。同样的按层上传逻辑也存在于 DataArrayTexture 分支(WebGLTextures.js),可见这是 three.js 对 2D 纹理数组统一的局部更新基础设施。

六、实战示例:KTX2 动画帧的按层更新

官方示例 webgl_texture2darray_layerupdate.html 完整演示了该类的典型用法:加载一张 KTX2 压缩动画(多帧),把其中 3 帧写入一个 3 层的 CompressedArrayTexture,再通过 GUI 把任意源层“搬运”到目标层。关键代码(省略场景与 GUI 搭建):

import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';

const ktx2Loader = new KTX2Loader();
ktx2Loader.detectSupport( renderer );

// KTX2 动画源纹理(压缩数据)
const spiritedaway = await ktx2Loader.loadAsync( 'textures/spiritedaway.ktx2' );

// 单帧的字节长度:用于在压缩数据流中切分每一层
const layerByteLength = THREE.TextureUtils.getByteLength(
	spiritedaway.image.width, spiritedaway.image.height,
	spiritedaway.format, spiritedaway.type,
);

// 构造 3 层的压缩纹理数组(1 级 mipmap 占位,随后填充基础级数据)
const textureArray = new THREE.CompressedArrayTexture( [
	{ data: new Uint8Array( layerByteLength * 3 ),
	  width: spiritedaway.image.width,
	  height: spiritedaway.image.height }
], spiritedaway.image.width, spiritedaway.image.height, 3,
	spiritedaway.format, spiritedaway.type );

“搬运”某一帧到目标层的核心逻辑,正是文档中 addLayerUpdate 的标准姿势:

const layerElementLength = layerByteLength / spiritedaway.mipmaps[ 0 ].data.BYTES_PER_ELEMENT;

// 1) 在 CPU 端把源层压缩数据写入目标层对应的字节区间
textureArray.mipmaps[ 0 ].data.set(
	spiritedaway.mipmaps[ 0 ].data.subarray(
		layerElementLength * srcLayer,
		layerElementLength * ( srcLayer + 1 )
	),
	layerByteLength * destLayer,
);

// 2) 标记该层 + 置位 needsUpdate,渲染时只上传这一层
textureArray.addLayerUpdate( destLayer );
textureArray.needsUpdate = true;
renderer.render( scene, camera );

采样端使用 sampler2DArray,第三维坐标即层索引:

precision highp sampler2DArray;
uniform sampler2DArray diffuse;
// ...
outColor = texture( diffuse, vec3( vUv, diffuseIndex ) );

该示例值得注意的两个细节:其一,压缩数据无法逐像素读写,因此“更新一层”必须在 CPU 端按 getByteLength 计算的字节偏移做 subarray/set 级别的搬运,再交给 GPU 上传路径完成局部刷新;其二,初始填充(mipmaps[0].data.set(...) + needsUpdate = true)走的是整幅上传路径,之后的单帧切换才走 addLayerUpdate 的局部路径。

七、自动创建路径:KTX2Loader 何时产出 CompressedArrayTexture

除手动构造外,three.js 生态中最常见的创建方式是通过加载器自动产出。KTX2Loader.js_createTextureFrom 中:

if ( container.faceCount === 6 ) {
	texture = new CompressedCubeTexture( faces, format, type );
} else {
	const mipmaps = faces[ 0 ].mipmaps;
	texture = container.layerCount > 1
		? new CompressedArrayTexture( mipmaps, width, height, container.layerCount, format, type )
		: new CompressedTexture( mipmaps, width, height, format, type );
}

即:KTX2 容器层数为 1 时得到普通 CompressedTexture,层数大于 1 时自动升级为 CompressedArrayTexture(立方体 6 面则走 CompressedCubeTexture)。加载器随后统一设置 minFilter(mipmap 数 > 1 时用 LinearMipmapLinearFilter,否则 LinearFilter)、magFilter = LinearFiltergenerateMipmaps = falseneedsUpdate = true,并按容器元数据设置 colorSpacepremultiplyAlpha。使用 KTX2Loader.js 前务必先调用 ktx2Loader.detectSupport( renderer ) 探测当前显卡支持的压缩格式——这一点在示例 webgl_texture2darray_layerupdate.html 与 WebGL 上传分支的告警逻辑(glFormat === null 时警告 “Attempt to load unsupported compressed texture format”)中都有体现。

八、使用注意事项小结

  1. mipmap 必须自备generateMipmaps 被强制为 false,若只传基础级数据且期望 trilinear 采样,应像 KTX2Loader 那样把 minFilter 设为 LinearFilter,否则会出现缺 mipmap 的采样问题;
  2. flipY 无效:压缩纹理上传不能翻转,图像方向需在编码阶段处理(示例着色器中手动做了 vUv.y = 1.0 - vUv.y 的处理);
  3. format/type 必须与压缩数据匹配:上传路径按 format 判断走 compressedTexSubImage3D 还是普通 texSubImage3D,格式不匹配会导致警告或错误采样;
  4. 按层更新的触发条件addLayerUpdate 只是登记意图,真正生效需要下一次 needsUpdate = true 且渲染器执行上传;上传完成后 layerUpdates 会被自动清空,重复登记同一层无副作用(Set 去重);
  5. 与 DataArrayTexture 的选型:原始未压缩的层数据用 DataArrayTexture,GPU 不支持的压缩格式或需压缩带宽时再用 CompressedArrayTexture;两者共享同一套按层上传机制(见 WebGLTextures.jsisDataArrayTexture 分支)。

参考资料(均在当前仓库内)

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