A comprehensive guide to optimization patterns in Three.js Shading Language (TSL) and WebGPU Renderer.
- TSL Caching Mechanisms
- Expression Optimization
- Node Optimization Patterns
- Function Definition Patterns
- Loop Optimization
- Compute Shader Optimizations
- Workgroup & Shared Memory
- Subgroup Operations
- Atomic Operations
- Texture Optimizations
- Buffer Access Patterns
- Post-Processing Optimizations
- Advanced Patterns
- Performance Checklist
- Fastest Texture Transfer Methods
- Partial Update Techniques
- Reversed Depth Buffer & Coordinate Systems
Prevents cross-contamination of node data during shader compilation.
import { isolate } from 'three/tsl';
// Isolate expensive sub-expressions
const isolatedResult = expensiveCalculation.isolate();
// Use when reusing complex node hierarchies in different contexts
const reusablePattern = Fn(([input]) => {
return input.mul(2).add(noise).isolate();
});When to Use:
- Reusing complex node hierarchies in different contexts
- Ensuring deterministic compilation independent of build context
- Preventing cache pollution from sub-expressions
Automatic garbage collection for node data.
// Internal mechanism - nodes automatically use WeakMap caching
// Parent cache chain allows hierarchical lookups
class NodeCache {
constructor(parent = null) {
this.nodesData = new WeakMap(); // Auto GC when nodes disposed
this.parent = parent; // Fallback chain
}
}Vector swizzles are automatically cached:
const color = vec4(1, 0, 0, 1);
// These create cached SplitNode instances
color.rgb; // Cached
color.xyz; // Same cache entry as .rgb
color.r; // Cached separately
// Array element access also cached
const arr = uniformArray([...]);
arr[0]; // Cached ArrayElementNode
arr[1]; // Cached separatelyPrevents recalculation of expressions used multiple times.
// BAD: Recalculates expensive operation twice
material.colorNode = expensiveNoise.add(1);
material.roughnessNode = expensiveNoise.mul(0.5);
// GOOD: Calculate once, reuse
const noise = expensiveNoise.toVar('noise');
material.colorNode = noise.add(1);
material.roughnessNode = noise.mul(0.5);Enables WebGPU const declarations for deterministic values.
// Creates 'const' in WGSL (faster than 'let')
const size = textureSize(myTexture).toConst('texSize');
const index = instanceIndex.mul(4).toConst();
// Deterministic expressions automatically detected
// Math nodes with constant inputs become const
const pi2 = float(Math.PI * 2).toConst();Only creates variable if actually needed (assigned to).
const result = expensiveCalculation.toVarIntent();
// Variable only created when assignment happens
If(condition, () => {
result.assign(newValue); // Now creates the variable
});The compiler automatically detects compile-time constants:
// These are detected as deterministic (become const):
float(1.0).add(2.0) // Const + Const
vec3(1, 2, 3).normalize() // Const vector operation
sin(float(Math.PI)) // Math on const
// These are NOT deterministic:
time.mul(2.0) // time is uniform
uv().x // UV varies per fragment
instanceIndex // Varies per instanceAutomatically creates variables for nodes used more than once.
// Internal optimization - happens automatically
// First use: usageCount = 1 (inline)
// Second use: usageCount = 2, creates variable
// Subsequent uses: reuse variable
// You can extend TempNode for custom nodes that benefit from this
class MyExpensiveNode extends TempNode {
// Automatically cached when used multiple times
}Execute functions without affecting the output chain.
import { bypass } from 'three/tsl';
// Run void function but output color unchanged
material.colorNode = myColor.bypass(runDebugVisualization());
// Useful for:
// - Debug logging
// - Statistics gathering
// - Setup/cleanup operationsUnderstanding the build phases helps optimize custom nodes:
class OptimizedNode extends Node {
// Phase 1: Transform and prepare nodes
setup(builder) {
// Create optimized node variants
return this.optimizedVariant || this;
}
// Phase 2: Analyze usage patterns
analyze(builder) {
// Track dependencies and usage counts
super.analyze(builder);
}
// Phase 3: Generate shader code
generate(builder) {
// Make decisions based on analysis
if (this.usedMultipleTimes) {
return this.generateAsVariable(builder);
}
return this.generateInline(builder);
}
}Explicit function layout prevents duplicate definitions:
// Define reusable shader function
const calculateNoise = Fn(([position, scale]) => {
const p = position.mul(scale);
return sin(p.x).mul(cos(p.y)).mul(sin(p.z));
}).setLayout({
name: 'calculateNoise',
type: 'float',
inputs: [
{ name: 'position', type: 'vec3' },
{ name: 'scale', type: 'float' }
]
});
// Called multiple times but only one function definition generated
const noise1 = calculateNoise(positionWorld, 1.0);
const noise2 = calculateNoise(positionWorld, 2.0);// Base function
const sphericalToVec3 = Fn(([phi, theta]) => {
const sinPhi = sin(phi);
return vec3(
sinPhi.mul(sin(theta)),
cos(phi),
sinPhi.mul(cos(theta))
);
}).setLayout({
name: 'sphericalToVec3',
type: 'vec3',
inputs: [
{ name: 'phi', type: 'float' },
{ name: 'theta', type: 'float' }
]
});
// Higher-level function using base
const randomDirection = Fn(([seed]) => {
const phi = hash(seed).mul(PI).mul(2);
const theta = hash(seed.add(1)).mul(PI);
return sphericalToVec3(phi, theta);
});// Marked as PURE - removed if unused by bundler
export const myOptimization = /*@__PURE__*/ Fn(([input]) => {
return input.mul(2);
});
// Pre-created constants use PURE
export const EPSILON = /*@__PURE__*/ float(1e-6);
export const PI = /*@__PURE__*/ float(Math.PI);import { Loop, Break, Continue } from 'three/tsl';
// Simple count loop
Loop(10, ({ i }) => {
// for (int i = 0; i < 10; i++)
});
// Range with custom conditions
Loop({
type: 'float',
start: float(1),
end: float(10),
condition: '<='
}, ({ i }) => {
// for (float i = 1.0; i <= 10.0; i++)
});
// Nested loops (compact)
Loop(10, 5, ({ i, j }) => {
// Generates proper variable names (i, j, k...)
});
// While-style
const value = float(0).toVar();
Loop(value.lessThan(10), () => {
value.addAssign(1);
});
// Custom step
Loop({
type: 'int',
start: 0,
end: 100,
update: (i) => i + 4 // Step by 4
}, ({ i }) => {
// Process every 4th element
});
// Control flow
Loop(count, ({ i }) => {
If(shouldSkip, () => Continue());
If(shouldStop, () => Break());
});// Small fixed loops may be unrolled by compiler
Loop(4, ({ i }) => {
// Compiler may unroll this
});
// Large/dynamic loops won't unroll
Loop(uniformCount, ({ i }) => {
// Always generates actual loop
});
// Manual unrolling for small counts
const sum = input[0].add(input[1]).add(input[2]).add(input[3]);import {
Fn, instanceIndex, instancedArray,
storage, uniform
} from 'three/tsl';
const count = 262144; // 2^18 particles
// Create storage buffers
const positionBuffer = instancedArray(count, 'vec3');
const velocityBuffer = instancedArray(count, 'vec3');
// Compute kernel
const updateParticles = Fn(() => {
const position = positionBuffer.element(instanceIndex);
const velocity = velocityBuffer.element(instanceIndex);
// Physics update
velocity.addAssign(gravity.mul(deltaTime));
position.addAssign(velocity.mul(deltaTime));
})().compute(count);
// Execute
renderer.compute(updateParticles);// Query device limits
const maxWorkgroupSize = renderer.backend.device.limits.maxComputeWorkgroupSizeX;
// Typical: 128, 256, or 1024
// Create with optimal size
const compute = Fn(() => {
// ...
})().compute(totalCount, [workgroupSize]);
// Guidelines:
// - Data aggregation: 64-256
// - Simple compute: 256-1024
// - Memory-intensive: 64-128 (more registers/shared memory per thread)Process multiple elements per thread for better efficiency:
const workPerThread = 4;
const totalThreads = Math.ceil(count / workPerThread);
const compute = Fn(() => {
const baseIndex = instanceIndex.mul(workPerThread);
Loop(workPerThread, ({ i }) => {
const index = baseIndex.add(i);
If(index.lessThan(count), () => {
// Process element
const value = inputBuffer.element(index);
outputBuffer.element(index).assign(value.mul(2));
});
});
})().compute(totalThreads);~100x faster than global buffer access.
import {
workgroupArray, workgroupBarrier,
invocationLocalIndex, workgroupId
} from 'three/tsl';
const workgroupSize = 256;
const reduceCompute = Fn(() => {
// Allocate shared memory (96KB max per workgroup)
const sharedData = workgroupArray('float', workgroupSize);
// Load global data into shared memory
sharedData.element(invocationLocalIndex).assign(
inputBuffer.element(instanceIndex)
);
// Synchronize all threads
workgroupBarrier();
// Parallel reduction in shared memory
let stride = workgroupSize / 2;
Loop({ start: stride, end: 0, condition: '>' }, () => {
If(invocationLocalIndex.lessThan(stride), () => {
sharedData.element(invocationLocalIndex).addAssign(
sharedData.element(invocationLocalIndex.add(stride))
);
});
workgroupBarrier();
stride = stride / 2;
});
// First thread writes result
If(invocationLocalIndex.equal(0), () => {
outputBuffer.element(workgroupId.x).assign(
sharedData.element(0)
);
});
})().compute(count, [workgroupSize]);Shared memory per workgroup: ~96KB typical
At 256 threads: 375 bytes/thread
At 64 threads: 1.5KB/thread
Hardware-accelerated operations within thread groups (32-64 threads).
import {
subgroupAdd, subgroupMin, subgroupMax,
subgroupInclusiveAdd, subgroupExclusiveAdd,
subgroupBallot, subgroupShuffle,
subgroupSize, invocationSubgroupIndex
} from 'three/tsl';
// Fast reduction (32-64x faster than barriers)
const sum = subgroupAdd(value);
const minimum = subgroupMin(value);
const maximum = subgroupMax(value);
// Prefix scan
const prefixSum = subgroupInclusiveAdd(value);
const exclusiveSum = subgroupExclusiveAdd(value);
// Voting
const ballot = subgroupBallot(condition);
// Data exchange
const neighborValue = subgroupShuffle(value, neighborIndex);const hierarchicalReduce = Fn(() => {
const total = inputBuffer.element(instanceIndex).toVar();
// Step 1: Subgroup reduction (fast)
total.assign(subgroupAdd(total));
// Step 2: Cross-subgroup reduction
const subgroupSums = workgroupArray('uint', workgroupSize);
workgroupBarrier();
If(invocationSubgroupIndex.equal(0), () => {
subgroupSums.element(subgroupMetaRank).assign(total);
});
workgroupBarrier();
// Final reduction
If(invocationLocalIndex.lessThan(subgroupCount), () => {
total.assign(subgroupSums.element(invocationLocalIndex));
total.assign(subgroupAdd(total));
});
})();Safe concurrent writes without race conditions.
import {
atomicAdd, atomicSub, atomicMin, atomicMax,
atomicAnd, atomicOr, atomicXor,
atomicLoad, atomicStore
} from 'three/tsl';
// Define atomic struct
const cellStruct = struct({
x: { type: 'int', atomic: true },
y: { type: 'int', atomic: true },
mass: { type: 'int', atomic: true }
});
const gridBuffer = instancedArray(gridSize, cellStruct);
// Use atomics for concurrent writes
const updateGrid = Fn(() => {
const cellIndex = computeCellIndex(position);
const cell = gridBuffer.element(cellIndex);
// Multiple threads can safely write
atomicAdd(cell.get('x'), encodeFixed(velocity.x));
atomicAdd(cell.get('y'), encodeFixed(velocity.y));
atomicAdd(cell.get('mass'), encodeFixed(mass));
})();
// Fixed-point encoding (WebGPU only supports int atomics)
const FIXED_MULTIPLIER = 1e7;
const encodeFixed = (f) => int(f.mul(FIXED_MULTIPLIER));
const decodeFixed = (i) => float(i).div(FIXED_MULTIPLIER);Performance Notes:
- Atomics are 10-50x slower than regular writes
- Minimize contention with spatial partitioning
- Consider separate buffers + reduction pass for light contention
import { texture, textureLoad, textureSize } from 'three/tsl';
// Filtered sampling (uses sampler)
const color = texture(map, uv());
// Direct texel fetch (no filtering, faster)
const texel = textureLoad(map, ivec2(x, y));
// With explicit LOD
const mipmapped = texture(map, uv(), level);
// With bias
const biased = texture(map, uv()).bias(2.0);
// Get dimensions
const size = textureSize(map);import { storageTexture, NodeAccess } from 'three/tsl';
// Read-only (optimized paths)
const readTex = storageTexture(tex).setAccess(NodeAccess.READ_ONLY);
// Write-only
const writeTex = storageTexture(tex).setAccess(NodeAccess.WRITE_ONLY);
// Read-write (slower)
const rwTex = storageTexture(tex).setAccess(NodeAccess.READ_WRITE);For iterative operations:
const pingTexture = new THREE.StorageTexture(width, height);
const pongTexture = new THREE.StorageTexture(width, height);
const readPing = storageTexture(pingTexture).setAccess(NodeAccess.READ_ONLY);
const writePong = storageTexture(pongTexture).setAccess(NodeAccess.WRITE_ONLY);
const readPong = storageTexture(pongTexture).setAccess(NodeAccess.READ_ONLY);
const writePing = storageTexture(pingTexture).setAccess(NodeAccess.WRITE_ONLY);
const computeToPong = Fn(() => {
const pos = ivec2(instanceIndex.mod(width), instanceIndex.div(width));
const value = textureLoad(readPing, pos);
// Process...
textureStore(writePong, pos, result);
})().compute(width * height);
const computeToPing = Fn(() => {
// Opposite direction
})().compute(width * height);
// Alternate each frame
let phase = false;
function animate() {
renderer.compute(phase ? computeToPong : computeToPing);
material.map = phase ? pongTexture : pingTexture;
phase = !phase;
}import { textureLevel } from 'three/tsl';
const storageTexture = new THREE.StorageTexture(width, height);
storageTexture.mipmapsAutoUpdate = false;
// Generate mipmaps via compute
const generateMipmap = Fn(() => {
const srcLevel = textureLevel(storageTexture, currentLevel);
const dstLevel = textureLevel(storageTexture, currentLevel + 1);
// Downsample logic
const srcPos = ivec2(instanceIndex.mod(dstWidth), instanceIndex.div(dstWidth)).mul(2);
const avg = srcLevel.load(srcPos)
.add(srcLevel.load(srcPos.add(ivec2(1, 0))))
.add(srcLevel.load(srcPos.add(ivec2(0, 1))))
.add(srcLevel.load(srcPos.add(ivec2(1, 1))))
.div(4);
dstLevel.store(dstPos, avg);
})();4x throughput with vec4 loads:
// Input as vec4 instead of individual values
const inputVec4 = new THREE.StorageInstancedBufferAttribute(array, 4);
const inputStorage = storage(inputVec4, 'uvec4', count / 4);
const compute = Fn(() => {
const vec4Value = inputStorage.element(instanceIndex);
// Sum components with single instruction
const sum = dot(uvec4(1), vec4Value);
// Or process individually
const a = vec4Value.x;
const b = vec4Value.y;
// ...
})();Only upload changed regions:
const buffer = new THREE.StorageBufferAttribute(array, 1);
// Mark modified region
buffer.addUpdateRange(startIndex, count);
// After GPU upload
buffer.clearUpdateRanges();// Enable async GPU->CPU transfer
const storage = instancedArray(array, 'uint')
.setPBO(true);
// Async readback
const data = await renderer.getArrayBufferAsync(storage.value);
const result = new Uint32Array(data);Single pass, multiple outputs:
import { mrt, pass, output, diffuseColor, normalView } from 'three/tsl';
const scenePass = pass(scene, camera);
scenePass.setMRT(mrt({
output: output,
diffuse: diffuseColor,
normal: directionToColor(normalView),
velocity: velocity
}));
// Extract outputs
const colorTex = scenePass.getTextureNode('output');
const diffuseTex = scenePass.getTextureNode('diffuse');
const normalTex = scenePass.getTextureNode('normal');
const depthTex = scenePass.getTextureNode('depth');// Use smaller types for non-HDR data
const diffuseTexture = scenePass.getTexture('diffuse');
diffuseTexture.type = THREE.UnsignedByteType; // 8-bit
const normalTexture = scenePass.getTexture('normal');
normalTexture.type = THREE.UnsignedByteType;
// Keep HDR for output
const outputTexture = scenePass.getTexture('output');
outputTexture.type = THREE.HalfFloatType; // 16-bitconst compositeEffect = Fn(() => {
const color = scenePass.getTextureNode('output');
const ao = aoPass.getTextureNode();
const gi = giPass.getTextureNode();
const diffuse = scenePass.getTextureNode('diffuse');
// Composite
return vec4(
color.rgb.mul(ao).add(diffuse.rgb.mul(gi)),
color.a
);
})();
postProcessing.outputNode = compositeEffect;const terrainElevation = Fn(([position]) => {
const elevation = float(0).toVar();
Loop({ type: 'float', start: 1, end: octaves, condition: '<=' }, ({ i }) => {
const freq = positionFrequency.mul(pow(2, i));
const amp = float(1).div(pow(2, i));
elevation.addAssign(noise3D(position.mul(freq)).mul(amp));
});
return elevation;
}).setLayout({
name: 'terrainElevation',
type: 'float',
inputs: [{ name: 'position', type: 'vec2' }]
});
material.positionNode = Fn(() => {
const pos = positionLocal.toVar();
const elevation = terrainElevation(pos.xz);
pos.y.addAssign(elevation);
// Compute normal via neighbors
const eps = 0.01;
const heightL = terrainElevation(pos.xz.sub(vec2(eps, 0)));
const heightR = terrainElevation(pos.xz.add(vec2(eps, 0)));
const heightD = terrainElevation(pos.xz.sub(vec2(0, eps)));
const heightU = terrainElevation(pos.xz.add(vec2(0, eps)));
vNormal.assign(normalize(vec3(
heightL.sub(heightR),
eps.mul(2),
heightD.sub(heightU)
)));
return pos;
})();const getSkinnedPosition = Fn(([position, boneMatrices, skinIndex, skinWeight]) => {
const boneMatX = boneMatrices.element(skinIndex.x);
const boneMatY = boneMatrices.element(skinIndex.y);
const boneMatZ = boneMatrices.element(skinIndex.z);
const boneMatW = boneMatrices.element(skinIndex.w);
// Linear blend skinning
const skinned = add(
boneMatX.mul(skinWeight.x).mul(position),
boneMatY.mul(skinWeight.y).mul(position),
boneMatZ.mul(skinWeight.z).mul(position),
boneMatW.mul(skinWeight.w).mul(position)
);
return skinned.xyz;
});// Store matrices in texture (bypasses UBO limits)
const matricesTexture = new THREE.DataTexture(...);
const size = textureSize(matricesTexture).x.toConst();
const getBatchMatrix = Fn(([batchId]) => {
const j = batchId.mul(4).toInt();
const x = j.mod(size);
const y = j.div(size);
return mat4(
textureLoad(matricesTexture, ivec2(x, y)),
textureLoad(matricesTexture, ivec2(x.add(1), y)),
textureLoad(matricesTexture, ivec2(x.add(2), y)),
textureLoad(matricesTexture, ivec2(x.add(3), y))
);
}).setLayout({
name: 'getBatchMatrix',
type: 'mat4',
inputs: [{ name: 'batchId', type: 'uint' }]
});| Technique | When to Use | Impact |
|---|---|---|
.toVar() |
Expression used 2+ times | High |
.toConst() |
Deterministic values | High |
.isolate() |
Reusable sub-expressions | Medium |
setLayout() |
Functions called multiple times | Medium |
@__PURE__ |
Exported utilities | Bundle size |
| Technique | When to Use | Speedup |
|---|---|---|
| Subgroup ops | Reductions, scans | 32-64x |
| Shared memory | Hot data caching | ~100x local |
| Vec4 access | Bulk data | 4x throughput |
| Work-per-thread | Memory-bound | 2-8x |
| Technique | When to Use | Benefit |
|---|---|---|
| Read-only access | Input buffers | Faster paths |
| Partial updates | Dynamic buffers | Less bandwidth |
| Ping-pong | Iterative compute | Avoids hazards |
| UnsignedByteType | Non-HDR textures | 4x less VRAM |
- Profile First - Use browser GPU profilers
- Minimize Barriers - Prefer subgroup ops
- Batch Operations - Fewer dispatches
- Async Everything - Use
getArrayBufferAsync - Right-size Workgroups - Balance occupancy vs resources
- Vectorize Access - Load vec4 when possible
- Avoid Atomics - Use reduction passes instead
- Cache Aggressively - Shared memory for reused data
examples/webgpu_compute_particles.html- 500k particle systemexamples/webgpu_compute_birds.html- Flocking simulationexamples/webgpu_compute_reduce.html- Parallel reductionexamples/webgpu_tsl_procedural_terrain.html- Procedural terrainexamples/webgpu_postprocessing_ssgi.html- Screen-space GI
src/nodes/core/IsolateNode.js- Cache isolationsrc/nodes/core/VarNode.js- Variable optimizationsrc/nodes/gpgpu/ComputeNode.js- Compute shaderssrc/nodes/gpgpu/WorkgroupInfoNode.js- Shared memorysrc/nodes/gpgpu/SubgroupFunctionNode.js- Subgroup opssrc/nodes/gpgpu/AtomicFunctionNode.js- Atomic operations
| Method | Use Case | Speed | CPU Overhead |
|---|---|---|---|
copyExternalImageToTexture |
Video, Canvas, ImageBitmap | Fastest | Zero-copy |
copyTextureToTexture |
GPU-to-GPU transfers | Fastest | None |
writeTexture |
TypedArray data | Fast | Memory copy |
| Compressed upload | KTX2, BC, ASTC | Fast | Decode only |
Best for: Video, Canvas, ImageBitmap, HTMLImageElement
// Internally used by VideoTexture, CanvasTexture
device.queue.copyExternalImageToTexture(
{
source: image, // HTMLImageElement, ImageBitmap, Canvas, VideoFrame
flipY: true
},
{
texture: textureGPU,
mipLevel: 0,
origin: { x: 0, y: 0, z: 0 },
premultipliedAlpha: false
},
{ width, height, depthOrArrayLayers: 1 }
);Why fastest: Direct GPU upload without CPU memory copy.
Frame-accurate video with minimal latency:
const video = document.getElementById('video');
const texture = new THREE.VideoTexture(video);
texture.colorSpace = THREE.SRGBColorSpace;
// Internally uses requestVideoFrameCallback for frame-accurate updates
// Falls back to manual update() if not supportedFor decoded VideoFrame objects:
const texture = new THREE.VideoFrameTexture();
const decoder = new VideoDecoder({
output(frame) {
// IMPORTANT: Close previous frame to prevent decoder stalls
if (texture.image instanceof VideoFrame) {
texture.image.close();
}
texture.setFrame(frame);
},
error(e) { console.error(e); }
});Best for: Post-processing, render target copies, partial updates
// Full texture copy
renderer.copyTextureToTexture(srcTexture, dstTexture);
// Partial region copy (fastest for updates)
renderer.copyTextureToTexture(
srcTexture,
dstTexture,
new THREE.Box2(
new THREE.Vector2(srcX, srcY),
new THREE.Vector2(srcX + width, srcY + height)
),
new THREE.Vector2(dstX, dstY),
srcMipLevel,
dstMipLevel
);Why fastest: GPU-side operation, no CPU involvement.
Best for: DataTexture, Data3DTexture, DataArrayTexture
// Standard DataTexture update
const data = new Float32Array(width * height * 4);
const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType);
texture.needsUpdate = true;
// Partial layer update for DataArrayTexture
const arrayTexture = new THREE.DataArrayTexture(data, width, height, depth);
arrayTexture.addLayerUpdate(layerIndex); // Only upload specific layer
arrayTexture.needsUpdate = true;// Option 1: CanvasTexture (simple)
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true; // Only when canvas changes
// Option 2: OffscreenCanvas + ImageBitmap (fastest for complex rendering)
const offscreen = new OffscreenCanvas(width, height);
const ctx = offscreen.getContext('2d');
// ... render to offscreen ...
const bitmap = await createImageBitmap(offscreen);
// bitmap goes through copyExternalImageToTexture (zero-copy)Method 1: copyTextureToTexture (Recommended)
// Create small source texture with updated region
const updateTexture = new THREE.DataTexture(
new Uint8Array(32 * 32 * 4),
32, 32
);
updateTexture.needsUpdate = true;
// Copy to destination at specific position
renderer.copyTextureToTexture(
updateTexture,
largeTexture,
null, // null = entire source
new THREE.Vector2(x, y) // destination position
);Method 2: DataArrayTexture layer updates
const arrayTexture = new THREE.DataArrayTexture(data, w, h, depth);
// Mark specific layers for update
arrayTexture.addLayerUpdate(5);
arrayTexture.addLayerUpdate(10);
arrayTexture.needsUpdate = true;
// After render, clear update list
arrayTexture.clearLayerUpdates();Supported formats: BC (S3TC/DXT), ETC2, ASTC, RGTC
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const ktx2Loader = new KTX2Loader()
.setTranscoderPath('basis/')
.detectSupport(renderer);
const texture = await ktx2Loader.loadAsync('texture.ktx2');
// Automatically uses optimal compressed format for device// 1. Disable mipmaps for streaming textures
texture.generateMipmaps = false;
// 2. Use appropriate format
texture.format = THREE.RGBAFormat; // Standard
texture.format = THREE.RedFormat; // Single channel (4x smaller)
texture.type = THREE.UnsignedByteType; // 8-bit (smallest)
texture.type = THREE.HalfFloatType; // 16-bit HDR
texture.type = THREE.FloatType; // 32-bit (largest)
// 3. Only update when needed
if (dataChanged) {
texture.needsUpdate = true;
}
// 4. Use correct color space for video
videoTexture.colorSpace = THREE.SRGBColorSpace; // Required!WebGPU requires 256-byte row alignment for writeTexture:
// Automatic in Three.js, but for custom uploads:
const bytesPerRow = Math.ceil(width * bytesPerTexel / 256) * 256;| Source | Three.js Class | Internal Method | Notes |
|---|---|---|---|
| HTML Video | VideoTexture |
copyExternalImageToTexture |
Use requestVideoFrameCallback |
| WebCodecs | VideoFrameTexture |
copyExternalImageToTexture |
Close old frames! |
| Canvas | CanvasTexture |
copyExternalImageToTexture |
Set needsUpdate manually |
| ImageBitmap | Any texture | copyExternalImageToTexture |
Pre-decode images |
| TypedArray | DataTexture |
writeTexture |
256-byte alignment |
| 3D Data | Data3DTexture |
writeTexture |
Layer iteration |
| Array | DataArrayTexture |
writeTexture |
Use addLayerUpdate |
| GPU Texture | - | copyTextureToTexture |
Fastest for copies |
| Framebuffer | FramebufferTexture |
copyTextureToTexture |
Screen capture |
| Protected | ExternalTexture |
Direct wrap | Zero transfer |
Minimizing data transfer by updating only changed portions of resources.
| Resource Type | Partial Update Method | Granularity |
|---|---|---|
| Textures | copyTextureToTexture |
Pixel region (Box2/Box3) |
| Texture Arrays | addLayerUpdate() |
Per-layer |
| Uniform Buffers | addUpdateRange() |
Byte range |
| Storage Buffers | addUpdateRange() |
Byte range |
| Attributes | addUpdateRange() |
Element range |
| Instanced Data | setMatrixAt() / setColorAt() |
Per-instance |
Best for: Updating regions of large textures without CPU involvement.
// Create a small texture with the updated region
const patchSize = 64;
const patchData = new Uint8Array(patchSize * patchSize * 4);
// ... fill patchData with new pixels ...
const patchTexture = new THREE.DataTexture(
patchData,
patchSize,
patchSize,
THREE.RGBAFormat
);
patchTexture.needsUpdate = true;
// Copy patch to large texture at specific position
renderer.copyTextureToTexture(
patchTexture, // source
largeTexture, // destination
null, // srcRegion (null = entire source)
new THREE.Vector2(x, y) // destination position
);// Copy specific region from source to destination
const srcRegion = new THREE.Box2(
new THREE.Vector2(srcX, srcY),
new THREE.Vector2(srcX + width, srcY + height)
);
const dstPosition = new THREE.Vector2(dstX, dstY);
renderer.copyTextureToTexture(
sourceTexture,
destinationTexture,
srcRegion,
dstPosition,
0, // srcMipLevel
0 // dstMipLevel
);// For Data3DTexture or 3D render targets
const srcRegion = new THREE.Box3(
new THREE.Vector3(srcX, srcY, srcZ),
new THREE.Vector3(srcX + width, srcY + height, srcZ + depth)
);
const dstPosition = new THREE.Vector3(dstX, dstY, dstZ);
renderer.copyTextureToTexture(
source3DTexture,
destination3DTexture,
srcRegion,
dstPosition
);Best for: Volumetric data, texture atlases, or sprite sheets where only specific layers change.
const depth = 100; // 100 layers
const arrayTexture = new THREE.DataArrayTexture(data, width, height, depth);
// Update only specific layers
function updateLayers(layerIndices) {
for (const layerIndex of layerIndices) {
// Modify data for this layer
const offset = layerIndex * width * height * 4;
// ... update data at offset ...
// Mark layer for upload
arrayTexture.addLayerUpdate(layerIndex);
}
arrayTexture.needsUpdate = true;
}
// Example: Update layers 5, 10, and 42
updateLayers([5, 10, 42]);
// After rendering, clear the update list
arrayTexture.clearLayerUpdates();Internal Behavior:
- Only marked layers are uploaded via
writeTexture - Other layers remain unchanged on GPU
- Significant bandwidth savings for sparse updates
import { uniform, uniformArray } from 'three/tsl';
// Create uniform buffer
const matrices = uniformArray(new Array(100).fill(new THREE.Matrix4()));
// Update specific range
function updateMatrixRange(startIndex, count, newMatrices) {
const buffer = matrices.value;
for (let i = 0; i < count; i++) {
buffer[startIndex + i].copy(newMatrices[i]);
}
// Mark only the changed range for upload
const floatsPerMatrix = 16;
const bytesPerFloat = 4;
const startByte = startIndex * floatsPerMatrix * bytesPerFloat;
const byteCount = count * floatsPerMatrix * bytesPerFloat;
matrices.buffer.addUpdateRange(startByte, byteCount);
matrices.buffer.needsUpdate = true;
}
// After upload, clear ranges
matrices.buffer.clearUpdateRanges();import { storage, instancedArray } from 'three/tsl';
// Create storage buffer
const positionBuffer = instancedArray(10000, 'vec3');
// Partial update pattern
function updatePositionRange(startIndex, positions) {
const buffer = positionBuffer.value;
const floatsPerVec3 = 3;
const bytesPerFloat = 4;
// Update data
for (let i = 0; i < positions.length; i++) {
const idx = (startIndex + i) * floatsPerVec3;
buffer.array[idx] = positions[i].x;
buffer.array[idx + 1] = positions[i].y;
buffer.array[idx + 2] = positions[i].z;
}
// Mark range
const startByte = startIndex * floatsPerVec3 * bytesPerFloat;
const byteCount = positions.length * floatsPerVec3 * bytesPerFloat;
buffer.addUpdateRange(startByte, byteCount);
buffer.needsUpdate = true;
}Best for: Geometry attributes (positions, normals, colors, UVs).
const geometry = new THREE.BufferGeometry();
const positions = new THREE.Float32BufferAttribute(vertexCount * 3, 3);
geometry.setAttribute('position', positions);
// Update specific vertex range
function updateVertices(startVertex, vertices) {
const array = positions.array;
for (let i = 0; i < vertices.length; i++) {
const idx = (startVertex + i) * 3;
array[idx] = vertices[i].x;
array[idx + 1] = vertices[i].y;
array[idx + 2] = vertices[i].z;
}
// Mark update range (in elements, not bytes)
positions.addUpdateRange(
startVertex * 3, // start (element index)
vertices.length * 3 // count (number of elements)
);
positions.needsUpdate = true;
}
// Clear after upload
positions.clearUpdateRanges();Best for: Large numbers of instances where only some change.
const count = 10000;
const mesh = new THREE.InstancedMesh(geometry, material, count);
// Update single instance
const matrix = new THREE.Matrix4();
const color = new THREE.Color();
function updateInstance(index, position, rotation, scale, instanceColor) {
matrix.compose(position, rotation, scale);
mesh.setMatrixAt(index, matrix);
if (instanceColor) {
mesh.setColorAt(index, color.set(instanceColor));
}
}
// Mark for update after changes
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) {
mesh.instanceColor.needsUpdate = true;
}// More efficient: batch multiple updates
function updateInstances(updates) {
let minIndex = Infinity;
let maxIndex = -Infinity;
for (const { index, position, rotation, scale } of updates) {
matrix.compose(position, rotation, scale);
mesh.setMatrixAt(index, matrix);
minIndex = Math.min(minIndex, index);
maxIndex = Math.max(maxIndex, index);
}
// Update range for instance matrices (16 floats per matrix)
const floatsPerMatrix = 16;
mesh.instanceMatrix.addUpdateRange(
minIndex * floatsPerMatrix,
(maxIndex - minIndex + 1) * floatsPerMatrix
);
mesh.instanceMatrix.needsUpdate = true;
}const batchedMesh = new THREE.BatchedMesh(maxGeometries, maxVertices, maxIndices);
// Update single instance in batch
function updateBatchInstance(instanceId, position, rotation, scale) {
matrix.compose(position, rotation, scale);
batchedMesh.setMatrixAt(instanceId, matrix);
}
// Update color
function updateBatchColor(instanceId, color) {
batchedMesh.setColorAt(instanceId, color);
}
// Change geometry for instance
function updateBatchGeometry(instanceId, geometryId) {
batchedMesh.setGeometryIdAt(instanceId, geometryId);
}
// Visibility control (no data transfer)
batchedMesh.setVisibleAt(instanceId, false);// Update specific morph target influences
mesh.morphTargetInfluences[0] = 0.5; // Only changed values uploaded
// For custom morph data updates
const morphAttribute = geometry.morphAttributes.position[0];
// Update range of morph vertices
morphAttribute.addUpdateRange(startVertex * 3, count * 3);
morphAttribute.needsUpdate = true;// Bones automatically track their own changes
// Only modified bone matrices are uploaded
// Manual optimization: freeze static bones
skeleton.bones.forEach((bone, index) => {
if (isStaticBone(bone)) {
bone.matrixAutoUpdate = false;
}
});
// Force update only for animated bones
animatedBones.forEach(bone => {
bone.updateMatrix();
bone.updateMatrixWorld(true);
});// For compute shaders, use storage buffers with update ranges
const computeBuffer = instancedArray(particleCount, 'vec4');
// After CPU modification of subset
function updateParticleRange(startIndex, count) {
const bytesPerVec4 = 16;
computeBuffer.value.addUpdateRange(
startIndex * bytesPerVec4,
count * bytesPerVec4
);
computeBuffer.value.needsUpdate = true;
}
// GPU-side updates don't need CPU transfer
// Compute shaders write directly to storage buffersThree.js uses version numbers to skip unchanged resources:
// Internal mechanism - happens automatically
texture.version++; // Incremented when needsUpdate = true
// Renderer checks:
if (textureData.version !== texture.version) {
// Upload texture
textureData.version = texture.version;
}
// Manual optimization: check before marking
if (dataActuallyChanged) {
texture.needsUpdate = true; // Increments version
}- Batch Updates - Accumulate changes, update once per frame
- Use Update Ranges - Always specify ranges for large buffers
- Clear Ranges - Call
clearUpdateRanges()after render - Avoid Full Uploads - Never set
needsUpdate = truefor unchanged data - GPU-Side When Possible - Use
copyTextureToTextureover CPU updates - Compute for Bulk Changes - Use compute shaders for large-scale modifications
| Method | Bandwidth | CPU Cost | GPU Cost |
|---|---|---|---|
| Full texture upload | High | High | Low |
copyTextureToTexture (region) |
Low | None | Low |
addLayerUpdate |
Medium | Low | Low |
Buffer addUpdateRange |
Low | Low | Low |
| Compute shader update | None | None | Medium |
// Heightmap with partial updates
const heightmapSize = 2048;
const heightmap = new THREE.DataTexture(
new Float32Array(heightmapSize * heightmapSize),
heightmapSize,
heightmapSize,
THREE.RedFormat,
THREE.FloatType
);
// Patch texture for updates
const patchSize = 64;
const patch = new THREE.DataTexture(
new Float32Array(patchSize * patchSize),
patchSize,
patchSize,
THREE.RedFormat,
THREE.FloatType
);
// Update terrain at brush position
function paintTerrain(worldX, worldZ, brushRadius, brushStrength) {
const texX = Math.floor((worldX / terrainSize + 0.5) * heightmapSize);
const texZ = Math.floor((worldZ / terrainSize + 0.5) * heightmapSize);
// Calculate affected region
const startX = Math.max(0, texX - patchSize / 2);
const startZ = Math.max(0, texZ - patchSize / 2);
// Read current values, apply brush, write to patch
// ... brush logic ...
patch.needsUpdate = true;
// GPU-side copy to heightmap
renderer.copyTextureToTexture(
patch,
heightmap,
null,
new THREE.Vector2(startX, startZ)
);
}Three.js supports two coordinate systems with different depth buffer conventions:
| Property | WebGL | WebGPU |
|---|---|---|
| Coordinate System | WebGLCoordinateSystem (2000) |
WebGPUCoordinateSystem (2001) |
| Depth Range | [-1, 1] (NDC) | [0, 1] (NDC) |
| Default Depth Test | LessEqualDepth |
LessEqualDepth |
| Extension Required | EXT_clip_control |
Native support |
Reversed depth (where near=1, far=0) dramatically improves depth precision:
Standard depth: Most precision near camera, poor precision far away
Near (0) ████████████████░░░░░░░░░░░░░░░░ Far (1)
└─ high precision ─┘ └─ low precision ─┘
Reversed depth: Even distribution of precision
Near (1) ████████████████████████████████ Far (0)
└────── uniform precision ──────┘
Why This Matters:
- Standard floating-point depth suffers from precision loss at distance
- Z-fighting artifacts are common for distant objects
- Reversed depth distributes precision more evenly across the view frustum
- Critical for large-scale scenes (terrain, flight simulators, space scenes)
In WebGLRenderer, reversed depth requires explicit configuration and hardware support:
// WebGL: Requires EXT_clip_control extension
const renderer = new THREE.WebGLRenderer({
reversedDepthBuffer: true // Enable if supported
});
// Check if actually available
if (renderer.capabilities.reversedDepthBuffer) {
console.log('Reversed depth buffer enabled');
} else {
console.log('EXT_clip_control not available, using standard depth');
}WebGL Implementation Details:
- Requires
EXT_clip_controlWebGL extension - Not universally supported on all hardware
- Camera's
_reversedDepthproperty is automatically set - Depth compare functions are automatically flipped
WebGPU uses a different coordinate system natively and handles depth differently:
// WebGPU coordinate system is 0-1 depth range by default
const renderer = new THREE.WebGPURenderer();
// The coordinate system is set automatically
console.log(renderer.coordinateSystem); // WebGPUCoordinateSystem (2001)
// Camera is automatically configured for WebGPU
// when first used with the renderer
renderer.render(scene, camera);
// After first render, camera is updated:
console.log(camera.coordinateSystem); // WebGPUCoordinateSystemShort Answer: Less critical, but still beneficial for extreme cases.
Detailed Analysis:
-
WebGPU's Native 0-1 Range:
- WebGPU already uses [0, 1] depth range instead of WebGL's [-1, 1]
- This provides better precision distribution than WebGL's standard depth
- The near plane maps to 0, far plane maps to 1
-
Camera
reversedDepthProperty:// Camera has reversedDepth property (defaults to false) camera.reversedDepth; // Read-only getter camera._reversedDepth; // Internal, can be set // Projection matrices account for reversed depth // See Matrix4.makePerspective() and Matrix4.makeOrthographic()
-
When Reversed Depth Helps in WebGPU:
- Extremely large far/near ratios (>100,000:1)
- Space simulations with objects from meters to thousands of kilometers
- Geological visualizations with extreme scale differences
-
Automatic Handling in Three.js:
- The renderer automatically updates camera coordinate system
- Frustum culling respects reversed depth settings
- Shadow cameras handle depth reversal correctly
Three.js automatically handles depth function flipping for reversed depth:
// Internal depth function mapping (WebGLState.js)
const reversedFuncs = {
[LessDepth]: GreaterDepth,
[LessEqualDepth]: GreaterEqualDepth,
[GreaterDepth]: LessDepth,
[GreaterEqualDepth]: LessEqualDepth,
// NeverDepth, AlwaysDepth, EqualDepth, NotEqualDepth unchanged
};
// In WebGPU, this is handled in pipeline creation
// See WebGPUPipelineUtils._getDepthCompare()The projection matrix calculation differs based on coordinate system and reversed depth:
// Matrix4.makePerspective handles all combinations:
matrix.makePerspective(
left, right, top, bottom,
near, far,
coordinateSystem, // WebGLCoordinateSystem or WebGPUCoordinateSystem
reversedDepth // true/false
);
// For reversed depth (simplified):
// c = near / (far - near)
// d = (far * near) / (far - near)
// For standard depth with WebGPU coordinate system:
// c = -far / (far - near)
// d = (-far * near) / (far - near)-
For WebGL:
// Enable reversed depth for large scenes const renderer = new THREE.WebGLRenderer({ reversedDepthBuffer: true, logarithmicDepthBuffer: false // Don't use both });
-
For WebGPU:
// Usually no special configuration needed const renderer = new THREE.WebGPURenderer(); // For extreme scales, consider: // - Cascaded shadow maps for shadows // - Multiple render passes with different near/far // - Camera-relative rendering for large worlds
-
Scene Scale Recommendations:
Scale Approach < 10,000 units Standard depth works fine 10,000 - 100,000 Consider reversed depth (WebGL) > 100,000 Use camera-relative rendering + floating origin -
Shadow Considerations:
// Light shadows automatically account for reversed depth // via LightShadow.js _frustum.setFromProjectionMatrix() // For point light shadows: // PointLightShadow handles coordinate system and reversed depth
// Check current configuration
console.log('Coordinate System:', renderer.coordinateSystem);
console.log('Camera Reversed Depth:', camera.reversedDepth);
// Visualize depth buffer in TSL
import { depth, viewZ } from 'three/tsl';
// Linear depth visualization
material.colorNode = depth; // Non-linear depth [0-1]
// Or linearize for visualization
const linearDepth = viewZ.negate().div(camera.far);
material.colorNode = vec3(linearDepth);| Renderer | Reversed Depth Status | Recommendation |
|---|---|---|
| WebGLRenderer | Optional, requires extension | Enable for large scenes |
| WebGPURenderer | Native 0-1 range provides good precision | Usually not needed; consider for extreme scales |
WebGPU's native coordinate system already provides improved depth precision over WebGL's standard configuration. The reversedDepth camera property exists for compatibility and edge cases, but most WebGPU applications won't need explicit reversed depth configuration.