threejs-performance

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Three.js Performance

Three.js 性能优化

Targets three.js r186. Examples use WebGLRenderer; the WebGPU and TSL section uses WebGPURenderer.
适用于 three.js r186。示例使用 WebGLRenderer;WebGPU 和 TSL 章节使用 WebGPURenderer。

Quick Start

快速开始

javascript
import * as THREE from "three";

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1));
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 6, 4);
scene.add(sun);

const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 2, 8);

const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), new THREE.MeshStandardMaterial({ color: 0x4488ff }));
scene.add(mesh);

function animate() {
  mesh.rotation.y += 0.01;
  renderer.render(scene, camera);
  // the counters reset at the start of every render() call, so these cover the frame just drawn
  console.log(`${renderer.info.render.calls} calls, ${renderer.info.render.triangles} triangles, ${renderer.info.memory.textures} textures`);
}

renderer.setAnimationLoop(animate);
javascript
import * as THREE from "three";

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1));
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 6, 4);
scene.add(sun);

const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 2, 8);

const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), new THREE.MeshStandardMaterial({ color: 0x4488ff }));
scene.add(mesh);

function animate() {
  mesh.rotation.y += 0.01;
  renderer.render(scene, camera);
  // the counters reset at the start of every render() call, so these cover the frame just drawn
  console.log(`${renderer.info.render.calls} calls, ${renderer.info.render.triangles} triangles, ${renderer.info.memory.textures} textures`);
}

renderer.setAnimationLoop(animate);

Measure First

先测量

Optimize what the numbers name, not what a hunch suspects. WebGLRenderer's
renderer.info
object is read after
render()
:
javascript
function animate() {
  renderer.render(scene, camera);
  console.table({
    calls: renderer.info.render.calls, // draw calls submitted by this render
    triangles: renderer.info.render.triangles,
    lines: renderer.info.render.lines,
    points: renderer.info.render.points,
    geometries: renderer.info.memory.geometries, // geometry buffers live on the GPU
    textures: renderer.info.memory.textures,
    programs: renderer.info.programs.length, // compiled shader programs in the cache
  });
}

renderer.setAnimationLoop(animate);
Details worth knowing when reading these:
  • The counters reset at the start of every
    render()
    call while
    info.autoReset
    is true (the default). Reading them before
    render()
    reports the previous frame, and a frame with several
    render()
    calls (picking, reflections, post passes) shows only the last one's numbers.
  • Shadow-map drawing runs through the same counters: each shadow-casting light re-draws its casters from the light's view inside
    render()
    , and those draws appear in
    calls
    and
    triangles
    . Toggling a light's
    castShadow
    is directly visible here.
  • info.memory.geometries
    and
    info.memory.textures
    count uploads, not bytes; they drop when the matching
    dispose()
    runs. Geometries and textures upload once and are shared by every mesh that references them.
When a frame really has several
render()
calls, accumulate over the whole frame yourself:
javascript
renderer.info.autoReset = false; // render() stops clearing the counters

function animate() {
  renderer.render(scene, camera);
  console.log(renderer.info.render.calls); // every render of this frame, summed
  renderer.info.reset(); // once per frame, after the last render
}

renderer.setAnimationLoop(animate);
Timer
gives the frame interval; average it over a second of frames, single frames jitter too much to compare. The interval never drops below one display refresh (16.7 ms at 60 Hz), so it shows dropped frames but not headroom; time the CPU side of the frame with
performance.now()
around the work:
javascript
const timer = new THREE.Timer();
timer.connect(document); // pauses while the tab is hidden

let interval = 0;
let cpu = 0;
let frames = 0;

function animate() {
  timer.update();
  interval += timer.getDelta(); // seconds since the previous frame, never less than one display refresh
  const start = performance.now();
  renderer.render(scene, camera);
  cpu += performance.now() - start; // CPU time spent in render(); the GPU executes the commands later
  frames++;
  if (frames === 60) {
    console.log(`${((interval / frames) * 1000).toFixed(2)} ms per frame, ${(cpu / frames).toFixed(2)} ms CPU in render()`);
    interval = 0;
    cpu = 0;
    frames = 0;
  }
}

renderer.setAnimationLoop(animate);
For anything deeper than frame time, use the browser profiler — the Chrome DevTools Performance panel shows long main-thread tasks, scripting versus rendering time, and GPU work on its GPU track. The
stats-gl
package shows a live per-frame statistics overlay when you want the numbers always visible.
应根据数据明确指出的问题进行优化,而不是依赖直觉猜测。WebGLRenderer 的
renderer.info
对象应在
render()
之后读取:
javascript
function animate() {
  renderer.render(scene, camera);
  console.table({
    calls: renderer.info.render.calls, // draw calls submitted by this render
    triangles: renderer.info.render.triangles,
    lines: renderer.info.render.lines,
    points: renderer.info.render.points,
    geometries: renderer.info.memory.geometries, // geometry buffers live on the GPU
    textures: renderer.info.memory.textures,
    programs: renderer.info.programs.length, // compiled shader programs in the cache
  });
}

renderer.setAnimationLoop(animate);
读取这些数据时,有以下情况值得了解:
  • 当
    info.autoReset
    为 true(默认值)时,计数器会在每次
    render()
    调用开始时重置。在
    render()
    之前读取会得到上一帧的数据;如果一帧中调用了多次
    render()
    (例如 picking、reflections 或 post pass),这里只会显示最后一次调用的数字。
  • shadow map 绘制也使用同一组计数器:在
    render()
    内部,每个投射阴影的灯光都会从灯光视角重新绘制其投射物,这些绘制会计入
    calls
    和
    triangles
    。切换灯光的
    castShadow
    后,可以直接在此观察到变化。
  • info.memory.geometries
    和
    info.memory.textures
    统计上传的资源,而不是字节数;执行对应的
    dispose()
    后,计数会下降。geometry 和 texture 只需上传一次,并由所有引用它们的 mesh 共享。
如果一帧中确实存在多个
render()
调用,请自行在整个帧范围内累加:
javascript
renderer.info.autoReset = false; // render() stops clearing the counters

function animate() {
  renderer.render(scene, camera);
  console.log(renderer.info.render.calls); // every render of this frame, summed
  renderer.info.reset(); // once per frame, after the last render
}

renderer.setAnimationLoop(animate);
Timer
可以提供帧间隔;应对一秒内的帧取平均值,因为单帧抖动太大,不适合比较。该间隔永远不会低于一次显示器刷新周期(60 Hz 下为 16.7 ms),因此它能显示掉帧,却无法反映余量;应使用
performance.now()
在工作前后对帧的 CPU 侧计时:
javascript
const timer = new THREE.Timer();
timer.connect(document); // pauses while the tab is hidden

let interval = 0;
let cpu = 0;
let frames = 0;

function animate() {
  timer.update();
  interval += timer.getDelta(); // seconds since the previous frame, never less than one display refresh
  const start = performance.now();
  renderer.render(scene, camera);
  cpu += performance.now() - start; // CPU time spent in render(); the GPU executes the commands later
  frames++;
  if (frames === 60) {
    console.log(`${((interval / frames) * 1000).toFixed(2)} ms per frame, ${(cpu / frames).toFixed(2)} ms CPU in render()`);
    interval = 0;
    cpu = 0;
    frames = 0;
  }
}

renderer.setAnimationLoop(animate);
要分析比帧时间更深入的问题,请使用浏览器 profiler——Chrome DevTools Performance 面板可以显示耗时过长的主线程任务、scripting 与 rendering 的耗时,以及 GPU 轨道上的 GPU 工作。
stats-gl
包可以显示实时逐帧统计浮层,方便始终查看这些数字。

Draw Calls

Draw Call

Each visible mesh queues one draw call, or one per geometry group when the material is an array, and two settings multiply that: a
transparent
material with
side: THREE.DoubleSide
draws each mesh twice, back faces then front faces (
forceSinglePass: true
makes it one), and while any MeshPhysicalMaterial with
transmission
above 0 is in view, every opaque object is drawn a second time into the transmission buffer. Without those two, a thousand single-material meshes is a thousand draw calls;
info.render.calls
is the number to watch. The classic reducers:
Merge static geometry. Many meshes that never move and share one material become one mesh and one call:
javascript
import * as BufferGeometryUtils from "three/addons/utils/BufferGeometryUtils.js";

const rockGeometry = new THREE.DodecahedronGeometry(1, 0);
const transform = new THREE.Matrix4();
const parts = [];
for (let i = 0; i < 200; i++) {
  const baked = rockGeometry.clone();
  transform.setPosition((Math.random() - 0.5) * 40, 0, (Math.random() - 0.5) * 40);
  baked.applyMatrix4(transform); // bake the placement into the vertices
  parts.push(baked);
}
const merged = BufferGeometryUtils.mergeGeometries(parts); // inputs need matching attributes and index state
scene.add(new THREE.Mesh(merged, new THREE.MeshNormalMaterial())); // 200 rocks, one call — but one bounding sphere for all of them
Merging trades culling granularity for the call count: the merged mesh has a single bounding sphere, so frustum culling is all-or-nothing. Keep world-sized clusters merged per cluster, not one merge for a whole level.
Instance repeats. One geometry drawn many times with per-instance transforms stays one call:
javascript
const tree = new THREE.ConeGeometry(0.5, 2, 6);
const trees = new THREE.InstancedMesh(tree, new THREE.MeshNormalMaterial(), 500);
const placement = new THREE.Object3D();
for (let i = 0; i < 500; i++) {
  placement.position.set((Math.random() - 0.5) * 40, 1, (Math.random() - 0.5) * 40);
  placement.rotation.y = Math.random() * 6.28;
  placement.updateMatrix();
  trees.setMatrixAt(i, placement.matrix);
}
// culling tests one sphere around all instances (computed lazily on the first cull test), so all 500 draw whenever it touches the view;
// after moving instances later, set trees.instanceMatrix.needsUpdate = true and call trees.computeBoundingSphere()
scene.add(trees);
Batch different geometries. Many geometries sharing one material pack into shared buffers:
javascript
const material = new THREE.MeshNormalMaterial();
const batchedMesh = new THREE.BatchedMesh(500, 20000, 20000, material);
const coneId = batchedMesh.addGeometry(new THREE.ConeGeometry(0.5, 2, 6));
const boxId = batchedMesh.addGeometry(new THREE.BoxGeometry(1, 1, 1));
const placement = new THREE.Matrix4();
for (let i = 0; i < 500; i++) {
  const instanceId = batchedMesh.addInstance(i % 2 ? coneId : boxId); // a geometry renders only through an instance
  placement.makeTranslation((Math.random() - 0.5) * 40, 1, (Math.random() - 0.5) * 40);
  batchedMesh.setMatrixAt(instanceId, placement);
}
scene.add(batchedMesh);
Unlike InstancedMesh, it culls and sorts each instance on its own (
perObjectFrustumCulled
and
sortObjects
, both on by default). WebGLRenderer multi-draws these in one call where the browser supports
WEBGL_multi_draw
, one draw per instance otherwise; the WebGPU backend issues one draw per instance.
threejs-geometry
covers both APIs in depth.
Drop detail with distance.
LOD
keeps every level as a child and, inside every
render()
(its
autoUpdate
is on by default), shows only the level for the current camera distance, so far objects submit fewer triangles — the call count stays the same:
javascript
const detailMaterial = new THREE.MeshNormalMaterial();
const lod = new THREE.LOD();
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32), detailMaterial), 0); // distances are camera-to-object
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 16, 8), detailMaterial), 12);
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 8, 4), detailMaterial), 30);
scene.add(lod);
Let culling skip whole calls. Frustum culling is on by default: before a mesh is queued, its geometry's bounding sphere is tested against the camera frustum, and a failed test removes the draw entirely. Two ways to lose it silently: editing vertices on the CPU without
geometry.computeBoundingSphere()
leaves a stale sphere — one left smaller than the moved vertices hides the mesh while it is still in view — and moving vertices in a shader leaves the CPU sphere untouched, so set
mesh.frustumCulled = false
on that one mesh, not scene-wide.
每个可见 mesh 都会排入一个 draw call;如果 material 是数组,则每个 geometry group 一个。此外,有两个设置会进一步放大调用次数:transparent material 配合
side: THREE.DoubleSide
会将每个 mesh 绘制两次,先背面再正面(
forceSinglePass: true
可将其变为一次);只要视野中存在任何
transmission
大于 0 的
MeshPhysicalMaterial
,所有不透明对象都会再次绘制到 transmission buffer 中。如果不存在这两种情况,一千个单材质 mesh 就是一千个 draw call;应重点关注
info.render.calls
。经典优化方法包括:
合并静态 geometry。 许多永不移动且共享同一 material 的 mesh 可以合并为一个 mesh 和一次调用:
javascript
import * as BufferGeometryUtils from "three/addons/utils/BufferGeometryUtils.js";

const rockGeometry = new THREE.DodecahedronGeometry(1, 0);
const transform = new THREE.Matrix4();
const parts = [];
for (let i = 0; i < 200; i++) {
  const baked = rockGeometry.clone();
  transform.setPosition((Math.random() - 0.5) * 40, 0, (Math.random() - 0.5) * 40);
  baked.applyMatrix4(transform); // bake the placement into the vertices
  parts.push(baked);
}
const merged = BufferGeometryUtils.mergeGeometries(parts); // inputs need matching attributes and index state
scene.add(new THREE.Mesh(merged, new THREE.MeshNormalMaterial())); // 200 rocks, one call — but one bounding sphere for all of them
合并会以 culling 粒度换取调用次数:合并后的 mesh 只有一个 bounding sphere,因此 frustum culling 只能整体进行或整体跳过。应按世界空间中的 cluster 分别合并,而不要把整个关卡合并为一体。
通过实例处理重复对象。 同一 geometry 以不同的实例变换绘制多次,仍然只需一次调用:
javascript
const tree = new THREE.ConeGeometry(0.5, 2, 6);
const trees = new THREE.InstancedMesh(tree, new THREE.MeshNormalMaterial(), 500);
const placement = new THREE.Object3D();
for (let i = 0; i < 500; i++) {
  placement.position.set((Math.random() - 0.5) * 40, 1, (Math.random() - 0.5) * 40);
  placement.rotation.y = Math.random() * 6.28;
  placement.updateMatrix();
  trees.setMatrixAt(i, placement.matrix);
}
// culling tests one sphere around all instances (computed lazily on the first cull test), so all 500 draw whenever it touches the view;
// after moving instances later, set trees.instanceMatrix.needsUpdate = true and call trees.computeBoundingSphere()
scene.add(trees);
批处理不同 geometry。 许多共享同一 material 的 geometry 可以打包到共享 buffer 中:
javascript
const material = new THREE.MeshNormalMaterial();
const batchedMesh = new THREE.BatchedMesh(500, 20000, 20000, material);
const coneId = batchedMesh.addGeometry(new THREE.ConeGeometry(0.5, 2, 6));
const boxId = batchedMesh.addGeometry(new THREE.BoxGeometry(1, 1, 1));
const placement = new THREE.Matrix4();
for (let i = 0; i < 500; i++) {
  const instanceId = batchedMesh.addInstance(i % 2 ? coneId : boxId); // a geometry renders only through an instance
  placement.makeTranslation((Math.random() - 0.5) * 40, 1, (Math.random() - 0.5) * 40);
  batchedMesh.setMatrixAt(instanceId, placement);
}
scene.add(batchedMesh);
与
InstancedMesh
不同,
BatchedMesh
会单独对每个实例进行 culling 和 sorting(
perObjectFrustumCulled
和
sortObjects
默认均开启)。在浏览器支持
WEBGL_multi_draw
时,WebGLRenderer 会在一次调用中执行 multi-draw,否则每个实例执行一次 draw;WebGPU 后端则为每个实例发出一次 draw。
threejs-geometry
对这两套 API 都有深入介绍。
按距离降低细节。
LOD
将每个 level 都保留为子对象,并在每次
render()
内部(其
autoUpdate
默认开启)仅显示与当前 camera distance 对应的 level,因此远处对象提交的 triangle 更少——call 数量保持不变:
javascript
const detailMaterial = new THREE.MeshNormalMaterial();
const lod = new THREE.LOD();
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32), detailMaterial), 0); // distances are camera-to-object
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 16, 8), detailMaterial), 12);
lod.addLevel(new THREE.Mesh(new THREE.SphereGeometry(1, 8, 4), detailMaterial), 30);
scene.add(lod);
让 culling 跳过整个 call。 frustum culling 默认开启:在 mesh 排入队列之前,系统会将 geometry 的 bounding sphere 与 camera frustum 进行测试,未通过测试时会完全移除该 draw。有两种方式会在不知情的情况下失去 culling:在 CPU 上编辑顶点后未调用
geometry.computeBoundingSphere()
会留下过期的 sphere——如果 sphere 比移动后的顶点更小,即使 mesh 仍在视野中也会被隐藏;在 shader 中移动顶点则不会改变 CPU 侧的 sphere,因此应只在该 mesh 上设置
mesh.frustumCulled = false
,而不要对整个 scene 这样做。

GPU Memory

GPU 内存

info.memory.textures
counts textures; the byte math is yours. An RGBA8 texture costs 4 bytes per pixel, and with the default mip chain WebGPURenderer's byte estimate multiplies by 1.333 — a 2048 by 2048 color map is about 22 MB of VRAM. Three levers:
  • Compressed KTX2. A Basis Universal KTX2 texture (ETC1S or UASTC) is transcoded to a block-compressed format the GPU supports (ASTC, BC7, BC1 or BC3, ETC2, ETC1 or PVRTC) and stays compressed in VRAM, instead of expanding to 4 bytes per pixel like a PNG or JPEG does; when none of them fits the file (ASTC takes UASTC files only, ETC1 has no alpha, PVRTC needs power-of-two sizes), KTX2Loader falls back to uncompressed RGBA. Load it with KTX2Loader:
javascript
import { KTX2Loader } from "three/addons/loaders/KTX2Loader.js";

const ktx2Loader = new KTX2Loader().detectSupport(renderer);
const map = await ktx2Loader.loadAsync("color.ktx2"); // a CompressedTexture for a Basis Universal file
map.colorSpace = THREE.SRGBColorSpace; // KTX2Loader already sets it from the file's transfer function; this line only matters for a color file tagged linear
mesh.material.map = map;
mesh.material.needsUpdate = true; // the material rendered without a map before
  • Size. A 1024 texture is a quarter of a 2048 one. Author color maps at the size they appear on screen, and let mipmapping handle distance.
  • Disposal.
    geometry.dispose()
    frees its GPU buffers at once;
    texture.dispose()
    frees the GPU texture once no other undisposed texture shares it (a
    texture.clone()
    shares the image, and its GPU copy while wrap, filter, format and color space stay the same). Nothing counts meshes, so dispose only what nothing still renders — a mesh that still uses a disposed geometry or texture uploads it again on its next render:
javascript
scene.remove(mesh);
mesh.geometry.dispose(); // frees the GPU buffers now, even if another mesh still uses this geometry

// material.dispose() releases its shader programs (deleted once no material uses them), never its textures
for (const value of Object.values(mesh.material)) {
  if (value?.isTexture) value.dispose();
}
mesh.material.dispose();
mesh.dispose(); // renderer-side state kept for this one object

console.log(renderer.info.memory.textures); // the count drops as each texture frees
To free a whole subtree, traverse it —
object.dispose()
does not visit children. Under WebGPURenderer
memory.geometries
and
memory.textures
stay counts, and the
*Size
fields and
memory.total
add byte estimates; see the WebGPU section.
info.memory.textures
统计 texture 数量;字节数需要自行计算。RGBA8 texture 每像素占 4 字节,使用默认 mip chain 时,WebGPURenderer 的字节估算会乘以 1.333——一张 2048 × 2048 的 color map 约占 22 MB VRAM。主要有三种手段:
  • 压缩 KTX2。 Basis Universal KTX2 texture(ETC1S 或 UASTC)会被转码为 GPU 支持的 block compressed format(ASTC、BC7、BC1 或 BC3、ETC2、ETC1 或 PVRTC),并始终以压缩形式驻留在 VRAM 中,而不像 PNG 或 JPEG 那样展开为每像素 4 字节;如果没有任何格式适配文件(ASTC 只接受 UASTC 文件、ETC1 不支持 alpha、PVRTC 要求尺寸为 2 的幂),KTX2Loader 会回退到未压缩的 RGBA。使用 KTX2Loader 加载:
javascript
import { KTX2Loader } from "three/addons/loaders/KTX2Loader.js";

const ktx2Loader = new KTX2Loader().detectSupport(renderer);
const map = await ktx2Loader.loadAsync("color.ktx2"); // a CompressedTexture for a Basis Universal file
map.colorSpace = THREE.SRGBColorSpace; // KTX2Loader already sets it from the file's transfer function; this line only matters for a color file tagged linear
mesh.material.map = map;
mesh.material.needsUpdate = true; // the material rendered without a map before
  • 尺寸。 1024 texture 占用 2048 texture 的四分之一。color map 应按其在屏幕上显示的尺寸制作,并让 mipmapping 处理远距离显示。
  • 资源释放。
    geometry.dispose()
    会立即释放其 GPU buffer;确认没有其他尚未释放的 texture 共享该资源后,
    texture.dispose()
    会释放 GPU texture(
    texture.clone()
    会共享 image;只要 wrap、filter、format 和 color space 保持不变,也会共享其 GPU 副本)。这些计数器不统计 mesh,因此只释放已不再被渲染的资源——仍在使用已释放 geometry 或 texture 的 mesh 会在下一次渲染时再次上传它们:
javascript
scene.remove(mesh);
mesh.geometry.dispose(); // frees the GPU buffers now, even if another mesh still uses this geometry

// material.dispose() releases its shader programs (deleted once no material uses them), never its textures
for (const value of Object.values(mesh.material)) {
  if (value?.isTexture) value.dispose();
}
material.dispose();
mesh.dispose(); // renderer-side state kept for this one object

console.log(renderer.info.memory.textures); // the count drops as each texture frees
要释放整个 subtree,需要遍历它——
object.dispose()
不会访问 children。在 WebGPURenderer 下,
memory.geometries
和
memory.textures
仍是计数,
*Size
字段和
memory.total
则提供字节估算;参见 WebGPU 章节。

Fill Rate

填充率

Fill rate is fragment work: pixels shaded per frame. The drawing buffer is
width × ratio
by
height × ratio
pixels, so shading cost scales with the pixel ratio squared — a 3× display shades 9× the pixels of 1×. Cap it:
javascript
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
Overdraw and transparency. Opaque geometry draws first (sorted by group, render order, material, then distance), transmissive next, transparent last — transparent back-to-front so blending composes correctly. Stacked transparent layers cannot reject each other: every layer behind another transparent surface still shades its pixels, and a full-screen transparent overlay shades every pixel of the screen once more on top of the scene. Prefer opaque materials where the look allows, and keep transparent quads few and small.
renderer.info
will not show overdraw — calls and triangles stay identical — only frame time moves.
Shadow maps. Each shadow-casting light renders its casters into a depth texture every frame while shadow updates are on. The defaults are small (512 by 512); raise them knowingly, and freeze them for static scenery:
javascript
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 6, 4);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);

renderer.shadowMap.enabled = true;

// static scenery: draw the shadow maps once instead of every frame
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true; // render them one more time, then freeze

// per light instead of globally: sun.shadow.autoUpdate = false; sun.shadow.needsUpdate = true;
Each shadow-casting light adds its own pass, and those draws appear in
info.render.calls
: it re-draws every
castShadow
object inside its shadow camera's frustum, a PointLight once per cube face the object falls in (up to six), and with
VSMShadowMap
the
receiveShadow
objects too, plus two blur draws. Tight shadow camera bounds (
sun.shadow.camera.left
,
right
,
top
,
bottom
, then
sun.shadow.camera.updateProjectionMatrix()
) cut the casters drawn; the map size sets each pass's fill cost.
填充率是指 fragment 工作量,即每帧进行 shading 的 pixel 数量。drawing buffer 的尺寸为
width × ratio
乘以
height × ratio
pixel,因此 shading 成本随 pixel ratio 的平方增长——3× 显示器需要 shading 的 pixel 是 1× 时的 9 倍。应限制 pixel ratio:
javascript
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
Overdraw 与 transparency。 opaque geometry 最先绘制(按 group、render order、material,然后是 distance 排序),接着是 transmissive,最后是 transparent——transparent 按从后到前排序,以确保 blending 正确。叠加的 transparent layer 无法彼此排除:位于另一个 transparent surface 之后的每一层仍会对其 pixel 进行 shading,而全屏 transparent overlay 会在 scene 之上再次 shading 屏幕上的每个 pixel。在效果允许的情况下优先使用 opaque material,并尽量减少 transparent quad 的数量和尺寸。
renderer.info
不会显示 overdraw——call 和 triangle 数量保持不变——只有 frame time 会发生变化。
Shadow map。 开启 shadow update 时,每个 cast shadow 的 light 每帧都会将其 caster 渲染到 depth texture 中。默认尺寸较小(512 × 512);在明确了解影响后再调高,并对静态场景冻结 shadow update:
javascript
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(3, 6, 4);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);

renderer.shadowMap.enabled = true;

// static scenery: draw the shadow maps once instead of every frame
renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true; // render them one more time, then freeze

// per light instead of globally: sun.shadow.autoUpdate = false; sun.shadow.needsUpdate = true;
每个 cast shadow 的 light 都会增加一个独立 pass,这些 draw 会计入
info.render.calls
:它会重新绘制 shadow camera frustum 内的每个
castShadow
对象;对于 PointLight,对象所在的每个 cube face 都会绘制一次(最多六次);使用
VSMShadowMap
时,
receiveShadow
对象也会被绘制,并额外进行两次 blur draw。收紧 shadow camera 边界(
sun.shadow.camera.left
、
right
、
top
、
bottom
,然后调用
sun.shadow.camera.updateProjectionMatrix()
)可以减少 caster 数量;map size 决定每个 pass 的 fill cost。

CPU Work in the Loop

循环中的 CPU 工作量

Static transforms. Every
render()
calls
scene.updateMatrixWorld()
, which visits every object and recomposes its local matrix from position, rotation and scale when
matrixAutoUpdate
is true (the default). For scenery that never moves, compose once:
javascript
mesh.matrixAutoUpdate = false;
mesh.updateMatrix(); // bake position/rotation/scale into mesh.matrix before the first render

// later, when this object does move:
mesh.position.set(1, 2, 3);
mesh.updateMatrix(); // manual now: the renderer no longer recomposes it
Raycasting.
intersectObjects(objects, true)
walks every descendant of every object and tests each one: a layers test first, then the geometry's bounding sphere, then triangles. Narrow the input instead of filtering the output:
javascript
const raycaster = new THREE.Raycaster();
raycaster.layers.set(1); // only objects enabled on layer 1 are tested, before any geometry work

const pickables = [];
scene.traverse((child) => {
  if (child.isMesh && child.userData.pickable) {
    child.layers.enable(1); // adds layer 1 while keeping layer 0 membership
    pickables.push(child);
  }
});

const hits = raycaster.intersectObjects(pickables, false); // an explicit list, no recursion
A pointermove handler that raycasts runs on every event, so this runs hot on scenes with picking. For large static geometry, the
three-mesh-bvh
package accelerates the triangle tests.
No allocations in the loop. Objects created per frame become garbage, and collection pauses show up as periodic frame spikes. Reuse vectors and matrices declared outside the loop:
javascript
const timer = new THREE.Timer();
timer.connect(document);

const step = new THREE.Vector3(); // allocated once, reused every frame
const target = new THREE.Vector3(0, 1, 0);

function animate() {
  timer.update();
  step.subVectors(target, mesh.position).multiplyScalar(Math.min(timer.getDelta() * 2, 1));
  mesh.position.add(step);
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);
静态 transform。 每次
render()
都会调用
scene.updateMatrixWorld()
,它会遍历每个 object;当
matrixAutoUpdate
为 true(默认)时,会根据 position、rotation 和 scale 重新组合 local matrix。对于永不移动的 scenery,只需组合一次:
javascript
mesh.matrixAutoUpdate = false;
mesh.updateMatrix(); // bake position/rotation/scale into mesh.matrix before the first render

// later, when this object does move:
mesh.position.set(1, 2, 3);
mesh.updateMatrix(); // manual now: the renderer no longer recomposes it
Raycasting。
intersectObjects(objects, true)
会遍历每个 object 的所有 descendant 并逐一测试:先执行 layers 测试,然后测试 geometry 的 bounding sphere,最后测试 triangle。应缩小输入范围,而不是在输出后过滤:
javascript
const raycaster = new THREE.Raycaster();
raycaster.layers.set(1); // only objects enabled on layer 1 are tested, before any geometry work

const pickables = [];
scene.traverse((child) => {
  if (child.isMesh && child.userData.pickable) {
    child.layers.enable(1); // adds layer 1 while keeping layer 0 membership
    pickables.push(child);
  }
});

const hits = raycaster.intersectObjects(pickables, false); // an explicit list, no recursion
如果 pointermove handler 执行 raycast,就会在每个 event 上运行,因此在包含 picking 的 scene 中,这部分开销始终处于高位。对于大型 static geometry,
three-mesh-bvh
包可以加速 triangle test。
循环中不进行 allocation。 每帧创建的 object 会成为 garbage,而 collection pause 会表现为周期性的 frame spike。应复用声明在循环外部的 vector 和 matrix:
javascript
const timer = new THREE.Timer();
timer.connect(document);

const step = new THREE.Vector3(); // allocated once, reused every frame
const target = new THREE.Vector3(0, 1, 0);

function animate() {
  timer.update();
  step.subVectors(target, mesh.position).multiplyScalar(Math.min(timer.getDelta() * 2, 1));
  mesh.position.add(step);
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

Shader Compile Stalls

Shader 编译卡顿

Programs build lazily: the first frame a material renders under conditions it has not seen before — a different light count, fog switched on for it — stalls while its shader compiles. Build them during loading instead:
javascript
// during loading, before the first frame: compile everything the scene will render with
await renderer.compileAsync(scene, camera); // uses KHR_parallel_shader_compile where available

renderer.setAnimationLoop(() => {
  renderer.render(scene, camera); // material programs are ready; shadow-depth programs still build on the first shadow render
});
renderer.compile(scene, camera)
issues the same builds but returns at once, so the first render still waits for any program that has not finished; prefer the async form. Changing a setting that is compiled into the shader —
transparent
,
side
,
blending
,
flatShading
,
vertexColors
,
dithering
,
premultipliedAlpha
,
alphaHash
, or adding or removing a map — needs
material.needsUpdate = true
and builds a new program, so avoid flipping these at runtime (
threejs-materials
lists them all).
Program 会延迟构建:material 首次在之前未遇到过的条件下渲染时——例如 light 数量不同、为它开启 fog——会因 shader 编译而卡顿。应在 loading 期间预先构建:
javascript
// during loading, before the first frame: compile everything the scene will render with
await renderer.compileAsync(scene, camera); // uses KHR_parallel_shader_compile where available

renderer.setAnimationLoop(() => {
  renderer.render(scene, camera); // material programs are ready; shadow-depth programs still build on the first shadow render
});
renderer.compile(scene, camera)
会触发相同的构建过程,但会立即返回,因此紧接着进行首次渲染时,仍会等待尚未完成的 program;应优先使用 async 形式。修改会被编译进 shader 的设置——
transparent
、
side
、
blending
、
flatShading
、
vertexColors
、
dithering
、
premultipliedAlpha
、
alphaHash
,或添加、移除 map——需要设置
material.needsUpdate = true
并构建新 program,因此应避免在 runtime 切换这些设置(
threejs-materials
列出了全部设置)。

WebGPU and TSL

WebGPU 与 TSL

javascript
import * as THREE from "three/webgpu";
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init(); // before any other use of the renderer
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111); // the WebGPU canvas is transparent by default
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 4, 18);
function animate() {
  renderer.render(scene, camera);
  // autoReset clears per-frame counters at each loop tick; render.calls counts render passes since the app started
  console.log(`${renderer.info.render.drawCalls} draw calls, ${renderer.info.memory.total} bytes of GPU memory`);
}
renderer.setAnimationLoop(animate);
Compute moves particle work off the CPU: no per-particle loop, no buffer re-upload per frame, one dispatch updates every particle:
javascript
import * as THREE from "three/webgpu";
import { Fn, cos, hash, instanceIndex, instancedArray, sin, time, TWO_PI, vec3 } from "three/tsl";
const count = 100000;
const positionBuffer = instancedArray(count, "vec3"); // a storage buffer living on the GPU
const simulate = Fn(() => {
  const rand = hash(instanceIndex);
  const angle = rand.mul(TWO_PI).add(time); // time updates itself every frame
  positionBuffer.element(instanceIndex).assign(vec3(cos(angle).mul(8), sin(rand.mul(97).add(time.mul(2))).mul(2).add(2), sin(angle).mul(8)));
});
const simulateCompute = simulate().compute(count); // one compute thread per particle
const material = new THREE.SpriteNodeMaterial();
material.positionNode = positionBuffer.toAttribute(); // the material reads the storage buffer directly
material.colorNode = vec3(1, 0.7, 0.3);
const particles = new THREE.Sprite(material);
particles.count = count; // the sprite draws once per buffer element
particles.frustumCulled = false; // culling tests one small sphere at the sprite origin, not the particles
scene.add(particles);
function animate() {
  renderer.compute(simulateCompute); // GPU updates the buffer, then the draw reads it
  renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
  • The WebGPU counters have their own names:
    info.render.drawCalls
    per frame,
    info.render.calls
    (render passes: each
    render()
    and internal ones such as the output pass) and
    info.calls
    (render and compute calls) cumulative since the app started,
    info.compute.frameCalls
    per frame, and
    info.memory.total
    — a byte estimate over vertex, index and storage buffers, textures, uniform buffers and programs (
    memory.texturesSize
    ,
    memory.attributesSize
    and the other
    *Size
    fields hold the parts). A compressed texture counts as 1 byte there, a fallback estimate, so KTX2 memory is under-reported.
  • The second block's loop replaces the first block's
    animate
    ;
    renderer.compute()
    submits directly because
    await renderer.init()
    has already run.
javascript
import * as THREE from "three/webgpu";
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init(); // before any other use of the renderer
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111); // the WebGPU canvas is transparent by default
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 4, 18);
function animate() {
  renderer.render(scene, camera);
  // autoReset clears per-frame counters at each loop tick; render.calls counts render passes since the app started
  console.log(`${renderer.info.render.drawCalls} draw calls, ${renderer.info.memory.total} bytes of GPU memory`);
}
renderer.setAnimationLoop(animate);
Compute 可以将 particle 工作负载从 CPU 移开:无需逐 particle 循环,也无需每帧重新上传 buffer,只需一次 dispatch 即可更新所有 particle:
javascript
import * as THREE from "three/webgpu";
import { Fn, cos, hash, instanceIndex, instancedArray, sin, time, TWO_PI, vec3 } from "three/tsl";
const count = 100000;
const positionBuffer = instancedArray(count, "vec3"); // a storage buffer living on the GPU
const simulate = Fn(() => {
  const rand = hash(instanceIndex);
  const angle = rand.mul(TWO_PI).add(time); // time updates itself every frame
  positionBuffer.element(instanceIndex).assign(vec3(cos(angle).mul(8), sin(rand.mul(97).add(time.mul(2))).mul(2).add(2), sin(angle).mul(8)));
});
const simulateCompute = simulate().compute(count); // one compute thread per particle
const material = new THREE.SpriteNodeMaterial();
material.positionNode = positionBuffer.toAttribute(); // the material reads the storage buffer directly
material.colorNode = vec3(1, 0.7, 0.3);
const particles = new THREE.Sprite(material);
particles.count = count; // the sprite draws once per buffer element
particles.frustumCulled = false; // culling tests one small sphere at the sprite origin, not the particles
scene.add(particles);
function animate() {
  renderer.compute(simulateCompute); // GPU updates the buffer, then the draw reads it
  renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
  • WebGPU 计数器使用不同的名称:
    info.render.drawCalls
    表示每帧 draw call;
    info.render.calls
    表示 render pass(每次
    render()
    以及 output pass 等内部 pass);
    info.calls
    表示应用启动以来 render 和 compute call 的累计值;
    info.compute.frameCalls
    表示每帧调用数;
    info.memory.total
    是对 vertex、index 和 storage buffer、texture、uniform buffer 及 program 的总字节估算(
    memory.texturesSize
    、
    memory.attributesSize
    和其他
    *Size
    字段保存各部分大小)。compressed texture 在该估算中按 1 字节计算,这只是 fallback estimate,因此 KTX2 内存占用会被低估。
  • 第二段代码中的 loop 会替换第一段代码的
    animate
    ;由于已经执行
    await renderer.init()
    ,
    renderer.compute()
    会直接提交。

Common Mistakes

常见错误

  • function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); }
    →
    renderer.setAnimationLoop(animate);
    : a WebXR session runs only the callback given to setAnimationLoop, through the session's own frame loop, and under WebGPURenderer setAnimationLoop also awaits init() before the first frame.
  • const clock = new THREE.Clock(); const dt = clock.getDelta(); const t = clock.getElapsedTime();
    →
    const timer = new THREE.Timer(); timer.update(); const dt = timer.getDelta(); const t = timer.getElapsed();
    : Clock is deprecated in r183, and getElapsedTime() calls getDelta() internally, so a later getDelta() in the same frame returns about zero.
  • console.log(renderer.info.render.calls); renderer.render(scene, camera);
    →
    renderer.render(scene, camera); console.log(renderer.info.render.calls);
    : the counters reset at the start of render(), so reading before it reports the previous frame.
  • renderer.info.autoReset = false
    →
    renderer.info.autoReset = false; function animate() { renderer.render(scene, camera); console.log(renderer.info.render.calls); renderer.info.reset(); }
    : with autoReset off, nothing clears the counters unless reset() runs once per frame after the last render(), so they accumulate across frames.
  • webgpuRenderer.info.render.calls
    →
    webgpuRenderer.info.render.drawCalls
    : under WebGPURenderer, render.calls counts render passes since the app started, while drawCalls counts this frame's draw calls; WebGLRenderer has no drawCalls, and its render.calls is the per-render() draw count.
  • renderer.compile(scene, camera)
    →
    await renderer.compileAsync(scene, camera)
    : compile() queues the builds and returns, so a render right after it still blocks on unfinished programs, while compileAsync() resolves only once KHR_parallel_shader_compile reports every program complete, where the browser exposes that extension.
  • renderer.shadowMap.type = THREE.PCFSoftShadowMap
    →
    renderer.shadowMap.type = THREE.PCFShadowMap
    : PCFSoftShadowMap is deprecated in r182 and falls back to PCF with a console warning.
  • mesh.material.dispose()
    →
    mesh.material.dispose(); for (const value of Object.values(mesh.material)) if (value?.isTexture) value.dispose();
    : material.dispose() releases its shader programs, never the material's textures, so info.memory.textures only drops when each texture.dispose() runs.
  • gltf.scene.dispose()
    →
    gltf.scene.traverse((child) => { child.geometry?.dispose(); for (const material of [child.material ?? []].flat()) { for (const value of Object.values(material)) if (value?.isTexture) value.dispose(); material.dispose(); } child.dispose(); })
    : Object3D.dispose() frees renderer state for that one object only, never its children, geometry, materials or textures.
  • const hits = raycaster.intersectObjects(scene.children, true)
    →
    raycaster.layers.set(1); const hits = raycaster.intersectObjects(pickables, false);
    : the recursive call walks and tests every descendant on every event, while the layers check runs before each object's geometry test and an explicit list never visits the rest of the scene.
  • scene.traverse((child) => (child.frustumCulled = false))
    →
    geometry.computeBoundingSphere(); // after moving vertices on the CPU
    : culling skips whole draw calls through each geometry's bounding sphere, so disabling it scene-wide draws everything every frame.
  • function animate() { mesh.position.add(new THREE.Vector3(0, 0.01, 0)); }
    →
    const offset = new THREE.Vector3(0, 0.01, 0); function animate() { mesh.position.add(offset); }
    : every per-frame allocation becomes garbage that the collector must pause to reclaim, which shows up as periodic frame spikes.
  • const dt = uniform(0); /* fed from the timer each frame */
    →
    const dt = deltaTime;
    : the built-in time and deltaTime nodes are uniforms the renderer already updates on every render, so a hand-fed copy duplicates them and needs its own Timer.
  • function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); }
    →
    renderer.setAnimationLoop(animate);
    :WebXR session 只会运行传给
    setAnimationLoop
    的 callback,并使用 session 自身的 frame loop;在 WebGPURenderer 下,
    setAnimationLoop
    还会在第一帧前等待
    init()
    完成。
  • const clock = new THREE.Clock(); const dt = clock.getDelta(); const t = clock.getElapsedTime();
    →
    const timer = new THREE.Timer(); timer.update(); const dt = timer.getDelta(); const t = timer.getElapsed();
    :Clock 在 r183 中已弃用,而且
    getElapsedTime()
    会在内部调用
    getDelta()
    ,因此同一帧中随后调用
    getDelta()
    会返回约 0。
  • console.log(renderer.info.render.calls); renderer.render(scene, camera);
    →
    renderer.render(scene, camera); console.log(renderer.info.render.calls);
    :计数器会在
    render()
    开始时重置,因此在之前读取会得到上一帧的数据。
  • renderer.info.autoReset = false
    →
    renderer.info.autoReset = false; function animate() { renderer.render(scene, camera); console.log(renderer.info.render.calls); renderer.info.reset(); }
    :关闭 autoReset 后,除非每帧最后一次
    render()
    之后运行一次
    reset()
    ,否则不会清除计数器,它们会跨帧持续累加。
  • webgpuRenderer.info.render.calls
    →
    webgpuRenderer.info.render.drawCalls
    :在 WebGPURenderer 下,
    render.calls
    统计应用启动以来的 render pass,而
    drawCalls
    统计本帧的 draw call;WebGLRenderer 没有
    drawCalls
    ,其
    render.calls
    是每次
    render()
    的 draw 数。
  • renderer.compile(scene, camera)
    →
    await renderer.compileAsync(scene, camera)
    :
    compile()
    会排队构建 program 后立即返回,因此紧随其后的 render 仍会阻塞在未完成的 program 上;而
    compileAsync()
    只有在
    KHR_parallel_shader_compile
    报告所有 program 完成后才 resolve,前提是浏览器提供该 extension。
  • renderer.shadowMap.type = THREE.PCFSoftShadowMap
    →
    renderer.shadowMap.type = THREE.PCFShadowMap
    :
    PCFSoftShadowMap
    在 r182 中已弃用,并会回退到 PCF,同时输出 console warning。
  • mesh.material.dispose()
    →
    mesh.material.dispose(); for (const value of Object.values(mesh.material)) if (value?.isTexture) value.dispose();
    :
    material.dispose()
    会释放 shader program,但绝不会释放 material 的 texture,因此只有每个
    texture.dispose()
    执行后,
    info.memory.textures
    才会下降。
  • gltf.scene.dispose()
    →
    gltf.scene.traverse((child) => { child.geometry?.dispose(); for (const material of [child.material ?? []].flat()) { for (const value of Object.values(material)) if (value?.isTexture) value.dispose(); material.dispose(); } child.dispose(); })
    :
    Object3D.dispose()
    仅释放该 object 的 renderer state,绝不会释放其 children、geometry、material 或 texture。
  • const hits = raycaster.intersectObjects(scene.children, true)
    →
    raycaster.layers.set(1); const hits = raycaster.intersectObjects(pickables, false);
    :recursive call 会在每个 event 中遍历并测试整个 scene 的所有 descendant,而 layers check 会在测试每个 object 的 geometry 之前执行,使用 explicit list 则不会访问 scene 的其余部分。
  • scene.traverse((child) => (child.frustumCulled = false))
    →
    geometry.computeBoundingSphere(); // after moving vertices on the CPU
    :culling 通过每个 geometry 的 bounding sphere 跳过整个 draw call,因此在 scene 范围内禁用 culling 会导致每帧绘制所有内容。
  • function animate() { mesh.position.add(new THREE.Vector3(0, 0.01, 0)); }
    →
    const offset = new THREE.Vector3(0, 0.01, 0); function animate() { mesh.position.add(offset); }
    :每次逐帧 allocation 都会成为需要 collector 回收的 garbage,从而表现为周期性的 frame spike。
  • const dt = uniform(0); /* fed from the timer each frame */
    →
    const dt = deltaTime;
    :内置的 time 和 deltaTime node 是 renderer 已在每次 render 时更新的 uniform,因此手动维护副本会重复这项工作,并且需要自己的 Timer。

See Also

相关主题

  • threejs-fundamentals
    - renderer construction, the render loop and resize handling around everything measured here.
  • threejs-geometry
    - the full instancing, batching, merging and attribute APIs behind the draw-call cuts.
  • threejs-materials
    - which material settings compile into the shader and force a rebuild.
  • threejs-loaders
    - the KTX2, Draco and meshopt loader wiring for the compressed files these levers produce.
  • threejs-lighting
    - light types, units and shadow setup whose costs this skill measures.
  • threejs-postprocessing
    - the composer's own buffers, MSAA and pixel-ratio settings.
  • threejs-asset-pipeline
    - the gltf-transform commands that produce KTX2 textures and compressed, instanced or joined glTF files.
  • threejs-fundamentals
    - 这里所测量内容周围的 renderer 构建、render loop 和 resize 处理。
  • threejs-geometry
    - 用于减少 draw call 的完整 instancing、batching、merging 和 attribute API。
  • threejs-materials
    - 哪些 material 设置会被编译进 shader 并强制重新构建。
  • threejs-loaders
    - 为这些优化手段生成的 compressed file 配置 KTX2、Draco 和 meshopt loader。
  • threejs-lighting
    - 本 skill 所测量的 light 类型、单位和 shadow 设置。
  • threejs-postprocessing
    - composer 自身的 buffer、MSAA 和 pixel ratio 设置。
  • threejs-asset-pipeline
    - 生成 KTX2 texture 以及 compressed、instanced 或 joined glTF file 的 gltf-transform 命令。