Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

Advanced TSL & WebGPU Renderer Optimization Techniques

A comprehensive guide to optimization patterns in Three.js Shading Language (TSL) and WebGPU Renderer.


Table of Contents

  1. TSL Caching Mechanisms
  2. Expression Optimization
  3. Node Optimization Patterns
  4. Function Definition Patterns
  5. Loop Optimization
  6. Compute Shader Optimizations
  7. Workgroup & Shared Memory
  8. Subgroup Operations
  9. Atomic Operations
  10. Texture Optimizations
  11. Buffer Access Patterns
  12. Post-Processing Optimizations
  13. Advanced Patterns
  14. Performance Checklist
  15. Fastest Texture Transfer Methods
  16. Partial Update Techniques
  17. Reversed Depth Buffer & Coordinate Systems

1. TSL Caching Mechanisms

IsolateNode - Cache Isolation

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

NodeCache - WeakMap-based Caching

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
  }
}

Swizzle Caching

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 separately

2. Expression Optimization

toVar() - Create Reusable Variables

Prevents 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);

toConst() - Compile-time Constants

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();

toVarIntent() - Lazy Variable Creation

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
});

Deterministic Detection

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 instance

3. Node Optimization Patterns

TempNode - Automatic Multi-use Detection

Automatically 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
}

BypassNode - Side Effects Without Output

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 operations

Three-Stage Build Process

Understanding 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);
  }
}

4. Function Definition Patterns

Fn() with setLayout()

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);

Nested Function Composition

// 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);
});

@PURE Annotations for Tree-Shaking

// 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);

5. Loop Optimization

Loop Constructs

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());
});

Loop Unrolling Considerations

// 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]);

6. Compute Shader Optimizations

Basic Compute Pattern

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);

Workgroup Size Tuning

// 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)

Work-Per-Thread Pattern

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);

7. Workgroup & Shared Memory

workgroupArray - Fast Local Memory

~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]);

Memory Budget

Shared memory per workgroup: ~96KB typical
At 256 threads: 375 bytes/thread
At 64 threads: 1.5KB/thread

8. Subgroup Operations

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);

Hierarchical Reduction Pattern

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));
  });

})();

9. Atomic Operations

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

10. Texture Optimizations

Texture Access Modes

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);

Storage Texture Access

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);

Ping-Pong Pattern

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;
}

Manual Mipmap Generation

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);
})();

11. Buffer Access Patterns

Vectorized Access

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;
  // ...
})();

Partial Buffer Updates

Only upload changed regions:

const buffer = new THREE.StorageBufferAttribute(array, 1);

// Mark modified region
buffer.addUpdateRange(startIndex, count);

// After GPU upload
buffer.clearUpdateRanges();

PBO Mode for Readback

// 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);

12. Post-Processing Optimizations

Multiple Render Targets (MRT)

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');

Bandwidth Optimization

// 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-bit

Effect Composition

const 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;

13. Advanced Patterns

Terrain with Procedural Normals

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;
})();

GPU Skinning with Storage Buffers

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;
});

Batch Rendering with Texture Matrices

// 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' }]
});

14. Performance Checklist

TSL Optimization

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

Compute Optimization

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

Memory Optimization

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

General Guidelines

  1. Profile First - Use browser GPU profilers
  2. Minimize Barriers - Prefer subgroup ops
  3. Batch Operations - Fewer dispatches
  4. Async Everything - Use getArrayBufferAsync
  5. Right-size Workgroups - Balance occupancy vs resources
  6. Vectorize Access - Load vec4 when possible
  7. Avoid Atomics - Use reduction passes instead
  8. Cache Aggressively - Shared memory for reused data

References

Example Files

  • examples/webgpu_compute_particles.html - 500k particle system
  • examples/webgpu_compute_birds.html - Flocking simulation
  • examples/webgpu_compute_reduce.html - Parallel reduction
  • examples/webgpu_tsl_procedural_terrain.html - Procedural terrain
  • examples/webgpu_postprocessing_ssgi.html - Screen-space GI

Source Files

  • src/nodes/core/IsolateNode.js - Cache isolation
  • src/nodes/core/VarNode.js - Variable optimization
  • src/nodes/gpgpu/ComputeNode.js - Compute shaders
  • src/nodes/gpgpu/WorkgroupInfoNode.js - Shared memory
  • src/nodes/gpgpu/SubgroupFunctionNode.js - Subgroup ops
  • src/nodes/gpgpu/AtomicFunctionNode.js - Atomic operations

15. Fastest Texture Transfer Methods

Transfer Method Hierarchy (Fastest to Slowest)

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

copyExternalImageToTexture (Zero-Copy Path)

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.

VideoTexture with requestVideoFrameCallback

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 supported

VideoFrameTexture (WebCodecs)

For 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); }
});

copyTextureToTexture (GPU-Side Transfer)

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.

writeTexture (Buffer Data)

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;

Fastest Canvas to Texture

// 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)

Partial Texture Updates

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();

Compressed Texture Upload

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

Optimization Tips

// 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!

Memory Alignment

WebGPU requires 256-byte row alignment for writeTexture:

// Automatic in Three.js, but for custom uploads:
const bytesPerRow = Math.ceil(width * bytesPerTexel / 256) * 256;

Transfer Summary by Source Type

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

16. Partial Update Techniques

Minimizing data transfer by updating only changed portions of resources.

Overview

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

Texture Partial Updates

Method 1: copyTextureToTexture (GPU-Side)

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
);

Method 2: Box2 Region Copy

// 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
);

Method 3: 3D Texture Region Copy (Box3)

// 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
);

DataArrayTexture Layer Updates

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

Buffer Partial Updates (Update Ranges)

Uniform Buffer 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();

Storage Buffer Updates

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;
}

BufferAttribute Update Ranges

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();

InstancedMesh Partial Updates

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;
}

Batch Instance Updates

// 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;
}

BatchedMesh Partial Updates

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);

Morph Target Updates

// 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;

Skeleton/Bone Updates

// 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);
});

Compute Buffer Partial Updates

// 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 buffers

Version-Based Dirty Tracking

Three.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
}

Partial Update Best Practices

  1. Batch Updates - Accumulate changes, update once per frame
  2. Use Update Ranges - Always specify ranges for large buffers
  3. Clear Ranges - Call clearUpdateRanges() after render
  4. Avoid Full Uploads - Never set needsUpdate = true for unchanged data
  5. GPU-Side When Possible - Use copyTextureToTexture over CPU updates
  6. Compute for Bulk Changes - Use compute shaders for large-scale modifications

Performance Comparison

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

Complete Example: Efficient Terrain Updates

// 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)
  );
}

17. Reversed Depth Buffer & Coordinate Systems

Understanding Coordinate System Differences

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 Buffer Benefits

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)

WebGL Renderer: Explicit Configuration

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_control WebGL extension
  • Not universally supported on all hardware
  • Camera's _reversedDepth property is automatically set
  • Depth compare functions are automatically flipped

WebGPU Renderer: Native Support

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); // WebGPUCoordinateSystem

Is Reversed Depth Still Relevant in WebGPU?

Short Answer: Less critical, but still beneficial for extreme cases.

Detailed Analysis:

  1. 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
  2. Camera reversedDepth Property:

    // 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()
  3. 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
  4. Automatic Handling in Three.js:

    • The renderer automatically updates camera coordinate system
    • Frustum culling respects reversed depth settings
    • Shadow cameras handle depth reversal correctly

Depth Function Mapping

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()

Projection Matrix Differences

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)

Best Practices

  1. For WebGL:

    // Enable reversed depth for large scenes
    const renderer = new THREE.WebGLRenderer({
      reversedDepthBuffer: true,
      logarithmicDepthBuffer: false  // Don't use both
    });
  2. 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
  3. 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
  4. 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

Debugging Depth Issues

// 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);

Summary

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.

About

A comprehensive guide to optimization patterns in Three.js Shading Language (TSL) and WebGPU Renderer.

Resources

Stars

22 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors