Performance Optimization

Comprehensive guide to optimizing streaming applications for maximum performance with focus on WebGPU rendering

Optimization Overview

Performance targets, workflow, and quick wins

Performance Optimization Overview

Optimize your streaming applications for the best possible performance and user experience.

Why Optimization Matters for Streaming

Streaming applications have unique performance requirements:

  • Low Latency: Every millisecond counts for responsive gameplay
  • Consistent Frame Rate: Smooth 60+ FPS prevents stream artifacts
  • Bandwidth Efficiency: Lower rendering load = better compression
  • Resource Management: Shared resources between game and streaming encoder
  • Battery Life: Mobile and laptop users need efficient rendering

Performance Targets

Frame Rate Targets

Use Case Target FPS Critical Threshold
Competitive Gaming 120-240 FPS 90 FPS
Action Games 60 FPS 45 FPS
Story/Adventure 30-60 FPS 30 FPS
VR/XR 90-120 FPS 90 FPS

Latency Targets

Metric Excellent Good Acceptable Poor
Input Lag <20ms 20-40ms 40-80ms >80ms
Frame Time <8ms (120fps) <16ms (60fps) <33ms (30fps) >33ms
Network RTT <20ms 20-50ms 50-100ms >100ms
Total Latency <50ms 50-100ms 100-150ms >150ms

Optimization Workflow

1. Measure First

Never optimize blindly. Always profile before making changes:

┌─────────────┐
│   Profile   │ ← Identify bottlenecks
└──────┬──────┘
       │
┌──────▼──────┐
│  Optimize   │ ← Make targeted changes
└──────┬──────┘
       │
┌──────▼──────┐
│  Validate   │ ← Measure improvement
└──────┬──────┘
       │
       └────────→ Repeat

2. Optimization Priority

Focus on the biggest bottlenecks first:

  1. GPU Bottlenecks (Most common for streaming)

    • Rendering complexity
    • Shader performance
    • Texture memory
    • Draw calls
  2. CPU Bottlenecks

    • Game logic
    • Physics simulation
    • AI processing
    • Animation updates
  3. Memory Issues

    • Texture streaming
    • Asset loading
    • Memory leaks
    • Garbage collection
  4. Network/Streaming

    • Bandwidth usage
    • Compression efficiency
    • Packet loss handling

Key Optimization Areas

Rendering Optimization

The most critical area for streaming applications:

Draw Call Reduction

Bad:  10,000 draw calls @ 60 FPS = bottleneck
Good: 2,000 draw calls @ 60 FPS = smooth

Shader Complexity

Bad:  100+ shader instructions per pixel
Good: 30-50 shader instructions per pixel

Texture Memory

Bad:  4K textures everywhere = 16MB per texture
Good: Mip-mapped, compressed textures = 2-4MB

See: Rendering Optimization Guide

Asset Optimization

Reduce memory footprint and load times:

Texture Compression

  • Use BC7 (desktop) or ASTC (mobile)
  • Enable mip-maps for all textures
  • Use texture atlases to reduce draw calls

Mesh Optimization

  • LOD (Level of Detail) systems
  • Occlusion culling
  • Mesh instancing

Audio Compression

  • Use Vorbis or Opus for music
  • Use ADPCM for sound effects
  • Stream large audio files

Code Optimization

Hot Path Optimization

// Bad - Allocates every frame
void Update() {
    std::vector<Entity> entities = GetEntities();
    for (auto& entity : entities) {
        entity.Update();
    }
}

// Good - Reuse allocations
std::vector<Entity> cachedEntities;

void Update() {
    GetEntities(cachedEntities); // Reuse vector
    for (auto& entity : cachedEntities) {
        entity.Update();
    }
}

Avoid Garbage Collection Spikes

// Bad - Creates garbage
void Update() {
    string message = "Player " + playerName + " scored!";
    Debug.Log(message);
}

// Good - No allocations
StringBuilder messageBuilder = new StringBuilder();

void Update() {
    messageBuilder.Clear();
    messageBuilder.Append("Player ");
    messageBuilder.Append(playerName);
    messageBuilder.Append(" scored!");
    Debug.Log(messageBuilder);
}

Quick Wins

Start with these high-impact, low-effort optimizations:

1. Enable Occlusion Culling

Don't render what you can't see:

Unreal Engine:

Project Settings → Rendering → Culling
✓ Occlusion Culling
✓ Use Precomputed Visibility

Unity:

Window → Rendering → Occlusion Culling
Bake Occlusion Data

2. Reduce Shadow Quality

Shadows are expensive:

High → Medium quality shadows = 20-30% FPS gain
Reduce shadow distance = 10-20% FPS gain
Use dynamic shadows only where needed

3. Optimize Post-Processing

Disable or reduce expensive effects:

Most Expensive:

  • Screen Space Reflections (SSR)
  • Ambient Occlusion (SSAO/HBAO)
  • Motion Blur (high quality)
  • Depth of Field

Lower Impact:

  • Bloom
  • Tone Mapping
  • Color Grading
  • Anti-aliasing (FXAA)

4. Texture Streaming

Enable virtual texturing or texture streaming:

Loads textures on-demand
Reduces memory usage by 50-70%
Slight pop-in vs. massive memory savings

5. Level of Detail (LOD)

Automatic mesh simplification:

Distance 0-10m:   100% detail (LOD0)
Distance 10-50m:  50% detail  (LOD1)
Distance 50-100m: 25% detail  (LOD2)
Distance 100m+:   10% detail  (LOD3)

Platform-Specific Optimization

Desktop (High-End)

Target: 1440p @ 120+ FPS

Focus Areas:

  • High-quality rendering
  • Advanced effects
  • Ray tracing (optional)
  • High texture quality

Desktop (Mid-Range)

Target: 1080p @ 60 FPS

Focus Areas:

  • Balanced quality/performance
  • Efficient rendering
  • Smart LOD usage
  • Texture streaming

Mobile/Low-End

Target: 720p @ 30-60 FPS

Focus Areas:

  • Aggressive culling
  • Low-poly models
  • Compressed textures
  • Minimal post-processing

Optimization Tools

Built-In Tools

Unreal Engine:

  • Stat FPS, Stat Unit, Stat SceneRendering
  • Unreal Insights
  • GPU Visualizer
  • Session Frontend

Unity:

  • Profiler Window
  • Frame Debugger
  • Memory Profiler
  • Physics Debugger

External Tools

GPU Profiling:

  • NVIDIA Nsight Graphics
  • AMD Radeon GPU Profiler
  • Intel GPA
  • RenderDoc

CPU Profiling:

  • Visual Studio Profiler
  • Intel VTune
  • Superluminal
  • Tracy Profiler

See: Profiling & Monitoring Guide

Performance Budgets

Set and maintain performance budgets:

Frame Time Budget (60 FPS = 16.67ms)

Game Logic:    3-4ms   (25%)
Rendering:     8-10ms  (60%)
Physics:       1-2ms   (10%)
Audio:         0.5ms   (3%)
Networking:    0.5ms   (3%)
Other:         1ms     (6%)

Memory Budget

Textures:      2-4 GB  (50-60%)
Meshes:        500 MB  (8-10%)
Audio:         500 MB  (8-10%)
Code/Scripts:  200 MB  (3-5%)
Game State:    300 MB  (5-8%)
Free:          1 GB    (15-20%)

Draw Call Budget

PC (High):     5,000-10,000 per frame
PC (Medium):   2,000-5,000 per frame
Mobile:        500-1,000 per frame
VR:            2,000-3,000 per frame (per eye)

Common Performance Pitfalls

1. Premature Optimization

Don't: Optimize before profiling ✅ Do: Profile first, then optimize hot spots

2. Over-Optimization

Don't: Sacrifice code quality for 0.1ms ✅ Do: Focus on meaningful improvements (>5%)

3. Ignoring Memory

Don't: Only focus on FPS ✅ Do: Monitor memory usage and leaks

4. Testing on High-End Only

Don't: Only test on development machines ✅ Do: Test on min-spec and target hardware

5. Skipping LODs

Don't: Use full detail everywhere ✅ Do: Implement aggressive LOD system

Optimization Checklist

Before launching, verify these optimizations:

Rendering:

  • Occlusion culling enabled
  • LOD system implemented
  • Texture compression enabled
  • Efficient shaders (profiled)
  • Draw calls minimized (<5k)

Assets:

  • Textures compressed and mip-mapped
  • Meshes optimized (reasonable poly count)
  • Audio compressed
  • Unused assets removed

Code:

  • Hot paths optimized
  • Object pooling for frequent allocations
  • Garbage collection minimized
  • Physics optimized

Memory:

  • No memory leaks
  • Texture streaming working
  • Asset loading optimized
  • Memory budget maintained

Testing:

  • Profiled on min-spec hardware
  • Tested for extended play sessions
  • Stress tested with many objects/players
  • Network conditions tested

Next Steps

Deep dive into specific optimization areas:

Resources

Official Documentation:

Tools:

Community:

Rendering Optimization

GPU optimization and WebGPU-specific techniques

Rendering Optimization

Optimize GPU rendering for maximum performance in streaming applications with focus on WebGPU.

GPU Performance Fundamentals

Understanding GPU Bottlenecks

The GPU pipeline has several potential bottlenecks:

┌──────────────┐
│ Vertex Fetch │ ← Input Assembly
└──────┬───────┘
       │
┌──────▼───────┐
│ Vertex Shade │ ← Transform vertices
└──────┬───────┘
       │
┌──────▼───────┐
│ Rasterization│ ← Create fragments
└──────┬───────┘
       │
┌──────▼───────┐
│ Pixel Shade  │ ← Color each pixel
└──────┬───────┘
       │
┌──────▼───────┐
│ Output Merge │ ← Blend & write
└──────────────┘

Common Bottlenecks:

  1. Vertex Shader Bound - Too many vertices
  2. Pixel Shader Bound - Complex shaders or high resolution
  3. Texture Bound - Too many or too large textures
  4. Bandwidth Bound - Memory transfer limitations
  5. Draw Call Bound - Too many draw calls (CPU)

Identifying Your Bottleneck

Test by reducing different aspects:

Halve Resolution:
  Big FPS gain   → Pixel Shader or Bandwidth bound
  Small FPS gain → Vertex or Draw Call bound

Halve Geometry:
  Big FPS gain   → Vertex Shader bound
  Small FPS gain → Pixel Shader or Bandwidth bound

Disable Textures:
  Big FPS gain   → Texture or Bandwidth bound
  Small FPS gain → Shader computation bound

WebGPU Optimization

WebGPU Overview

WebGPU is the modern graphics API for the web, replacing WebGL:

Advantages:

  • Lower overhead (closer to Vulkan/D3D12/Metal)
  • Better multi-threading support
  • Compute shaders
  • More efficient resource binding

Key Concepts:

  • Command Buffers (pre-record rendering)
  • Pipeline State Objects (pre-compiled states)
  • Bind Groups (efficient resource binding)
  • Compute Passes (GPU compute)

WebGPU Render Pipeline Optimization

// Efficient pipeline creation
const pipeline = device.createRenderPipeline({
	layout: pipelineLayout,
	vertex: {
		module: shaderModule,
		entryPoint: 'vertex_main',
		buffers: [
			{
				arrayStride: 32, // Aligned to 16 bytes
				attributes: [
					{ format: 'float32x3', offset: 0, shaderLocation: 0 }, // Position
					{ format: 'float32x3', offset: 12, shaderLocation: 1 }, // Normal
					{ format: 'float32x2', offset: 24, shaderLocation: 2 } // UV
				]
			}
		]
	},
	fragment: {
		module: shaderModule,
		entryPoint: 'fragment_main',
		targets: [
			{
				format: 'bgra8unorm',
				blend: {
					color: {
						operation: 'add',
						srcFactor: 'src-alpha',
						dstFactor: 'one-minus-src-alpha'
					},
					alpha: {
						operation: 'add',
						srcFactor: 'one',
						dstFactor: 'zero'
					}
				}
			}
		]
	},
	primitive: {
		topology: 'triangle-list',
		cullMode: 'back',
		frontFace: 'ccw'
	},
	depthStencil: {
		format: 'depth24plus',
		depthWriteEnabled: true,
		depthCompare: 'less'
	}
});

Bind Groups for Efficiency

Group resources by update frequency:

// Bind Group 0: Per-frame (camera, time)
const frameBindGroup = device.createBindGroup({
	layout: pipeline.getBindGroupLayout(0),
	entries: [
		{ binding: 0, resource: { buffer: cameraBuffer } },
		{ binding: 1, resource: { buffer: timeBuffer } }
	]
});

// Bind Group 1: Per-material (textures, properties)
const materialBindGroup = device.createBindGroup({
	layout: pipeline.getBindGroupLayout(1),
	entries: [
		{ binding: 0, resource: diffuseTexture.createView() },
		{ binding: 1, resource: normalTexture.createView() },
		{ binding: 2, resource: sampler },
		{ binding: 3, resource: { buffer: materialBuffer } }
	]
});

// Bind Group 2: Per-object (transform)
const objectBindGroup = device.createBindGroup({
	layout: pipeline.getBindGroupLayout(2),
	entries: [{ binding: 0, resource: { buffer: transformBuffer } }]
});

// Efficient rendering - only rebind what changes
passEncoder.setPipeline(pipeline);
passEncoder.setBindGroup(0, frameBindGroup); // Set once per frame

for (const material of materials) {
	passEncoder.setBindGroup(1, material.bindGroup); // Set per material

	for (const object of material.objects) {
		passEncoder.setBindGroup(2, object.bindGroup); // Set per object
		passEncoder.draw(object.vertexCount);
	}
}

Command Buffer Optimization

Pre-record command buffers for static scenes:

class SceneRenderer {
	constructor(device) {
		this.device = device;
		this.commandBuffers = new Map();
	}

	// Pre-record rendering commands
	recordScene(sceneId, renderables) {
		const encoder = this.device.createCommandEncoder();
		const passEncoder = encoder.beginRenderPass(renderPassDescriptor);

		for (const renderable of renderables) {
			passEncoder.setPipeline(renderable.pipeline);
			passEncoder.setVertexBuffer(0, renderable.vertexBuffer);
			passEncoder.setIndexBuffer(renderable.indexBuffer, 'uint16');
			passEncoder.drawIndexed(renderable.indexCount);
		}

		passEncoder.end();
		const commandBuffer = encoder.finish();

		this.commandBuffers.set(sceneId, commandBuffer);
	}

	// Submit pre-recorded commands
	renderScene(sceneId) {
		const commandBuffer = this.commandBuffers.get(sceneId);
		if (commandBuffer) {
			this.device.queue.submit([commandBuffer]);
		}
	}
}

WebGPU Compute Shaders

Offload work to compute shaders:

// Compute shader for particle simulation
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@group(0) @binding(1) var<uniform> params: SimParams;

@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) GlobalInvocationID: vec3<u32>) {
    let index = GlobalInvocationID.x;

    if (index >= params.particleCount) {
        return;
    }

    var particle = particles[index];

    // Update position
    particle.velocity += params.gravity * params.deltaTime;
    particle.position += particle.velocity * params.deltaTime;

    // Bounce off ground
    if (particle.position.y < 0.0) {
        particle.position.y = 0.0;
        particle.velocity.y = -particle.velocity.y * 0.8;
    }

    particles[index] = particle;
}
// Dispatch compute shader
const computePass = encoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, computeBindGroup);
computePass.dispatchWorkgroups(Math.ceil(particleCount / 256));
computePass.end();

Draw Call Optimization

Reduce Draw Calls

Target Numbers:

  • Desktop (High): <5,000 per frame
  • Desktop (Medium): <2,000 per frame
  • WebGPU: <1,000 per frame
  • Mobile: <500 per frame

Instancing

Render many copies efficiently:

// Vertex shader with instancing
struct InstanceData {
    @location(0) position: vec3<f32>,
    @location(1) rotation: vec4<f32>,
    @location(2) scale: vec3<f32>,
    @location(3) color: vec4<f32>
}

@vertex
fn vs_main(
    @location(0) vertex_pos: vec3<f32>,
    @location(1) vertex_normal: vec3<f32>,
    instance: InstanceData,
    @builtin(instance_index) instanceIdx: u32
) -> VertexOutput {
    // Transform vertex by instance data
    let worldPos = transformVertex(vertex_pos, instance);
    // ...
}
// Draw many instances with one call
passEncoder.draw(
	vertexCount,
	instanceCount, // Draw this many instances
	0,
	0
);

Batching

Combine meshes that share materials:

class MeshBatcher {
	constructor(device) {
		this.batches = new Map();
	}

	// Add mesh to batch
	addMesh(materialId, vertices, indices) {
		if (!this.batches.has(materialId)) {
			this.batches.set(materialId, {
				vertices: [],
				indices: [],
				indexOffset: 0
			});
		}

		const batch = this.batches.get(materialId);
		const currentIndexOffset = batch.vertices.length / vertexSize;

		// Add vertices
		batch.vertices.push(...vertices);

		// Add indices with offset
		for (const index of indices) {
			batch.indices.push(index + currentIndexOffset);
		}
	}

	// Create GPU buffers for each batch
	createBuffers(device) {
		const buffers = new Map();

		for (const [materialId, batch] of this.batches) {
			const vertexBuffer = device.createBuffer({
				size: batch.vertices.length * 4,
				usage: GPUBufferUsage.VERTEX,
				mappedAtCreation: true
			});
			new Float32Array(vertexBuffer.getMappedRange()).set(batch.vertices);
			vertexBuffer.unmap();

			const indexBuffer = device.createBuffer({
				size: batch.indices.length * 2,
				usage: GPUBufferUsage.INDEX,
				mappedAtCreation: true
			});
			new Uint16Array(indexBuffer.getMappedRange()).set(batch.indices);
			indexBuffer.unmap();

			buffers.set(materialId, { vertexBuffer, indexBuffer, indexCount: batch.indices.length });
		}

		return buffers;
	}
}

Shader Optimization

WGSL Shader Best Practices

// Efficient vertex shader
@vertex
fn vs_main(
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) uv: vec2<f32>
) -> VertexOutput {
    var output: VertexOutput;

    // Transform position
    output.position = camera.viewProj * vec4<f32>(position, 1.0);

    // Transform normal (no need for normalization if input is normalized)
    output.worldNormal = (model.transform * vec4<f32>(normal, 0.0)).xyz;

    output.uv = uv;

    return output;
}

// Efficient fragment shader
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
    // Sample texture
    let albedo = textureSample(albedoTexture, albedoSampler, input.uv);

    // Normalize only when needed
    let N = normalize(input.worldNormal);
    let L = normalize(light.direction);

    // Simple lighting
    let diffuse = max(dot(N, L), 0.0);

    return vec4<f32>(albedo.rgb * diffuse, albedo.a);
}

Avoid Expensive Operations

// ❌ BAD: Expensive operations
@fragment
fn bad_shader(input: VertexOutput) -> @location(0) vec4<f32> {
    // Avoid in fragment shader:
    var sum = 0.0;
    for (var i = 0; i < 100; i++) { // Loops
        sum += pow(f32(i), 2.0);    // Expensive math
    }

    let result = sqrt(sum) / 100.0;  // More expensive math
    return vec4<f32>(result);
}

// ✅ GOOD: Precompute or simplify
@group(0) @binding(0) var<uniform> precomputed: f32; // Computed on CPU

@fragment
fn good_shader(input: VertexOutput) -> @location(0) vec4<f32> {
    // Use precomputed value
    return vec4<f32>(precomputed);
}

Shader Instruction Count

Target instruction counts:

Simple Shaders:     <50 instructions
Medium Complexity:  50-100 instructions
Complex Shaders:    100-200 instructions
Very Complex:       200+ instructions (use sparingly)

Texture Optimization

Texture Compression

Use appropriate formats:

// Create compressed texture
const texture = device.createTexture({
	size: [width, height, 1],
	format: 'bc7-rgba-unorm', // Desktop
	// format: 'astc-4x4-unorm', // Mobile
	usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
	mipLevelCount: mipLevels
});

Format Guide:

Format Use Case Compression Ratio Quality
BC7 (DXT) Diffuse/Albedo (Desktop) 4:1 Excellent
BC5 Normal maps (Desktop) 4:1 Good
BC4 Grayscale (Desktop) 8:1 Good
ASTC Mobile textures 4:1 to 12:1 Excellent
ETC2 Mobile fallback 4:1 to 6:1 Good

Mip-Mapping

Always use mip-maps:

// Generate mip-maps
function generateMips(device, texture, width, height, mipLevels) {
	const encoder = device.createCommandEncoder();

	for (let i = 1; i < mipLevels; i++) {
		const srcMip = i - 1;
		const dstMip = i;

		// Blit and downsample
		const pass = encoder.beginRenderPass({
			colorAttachments: [
				{
					view: texture.createView({
						baseMipLevel: dstMip,
						mipLevelCount: 1
					}),
					loadOp: 'clear',
					storeOp: 'store'
				}
			]
		});

		// Set up downsample shader and draw
		pass.setPipeline(downsamplePipeline);
		pass.setBindGroup(0, createBindGroup(texture, srcMip));
		pass.draw(6); // Full-screen quad

		pass.end();
	}

	device.queue.submit([encoder.finish()]);
}

Texture Atlases

Combine textures to reduce bind operations:

class TextureAtlas {
	constructor(device, size = 2048) {
		this.size = size;
		this.packer = new BinPacker(size, size);

		this.texture = device.createTexture({
			size: [size, size, 1],
			format: 'rgba8unorm',
			usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
		});

		this.uvOffsets = new Map();
	}

	addTexture(id, imageData) {
		const rect = this.packer.pack(imageData.width, imageData.height);

		if (!rect) {
			console.warn('Atlas full, cannot add texture');
			return null;
		}

		// Upload to atlas
		device.queue.writeTexture(
			{ texture: this.texture, origin: [rect.x, rect.y, 0] },
			imageData.data,
			{ bytesPerRow: imageData.width * 4 },
			[imageData.width, imageData.height, 1]
		);

		// Store UV transform
		const uvOffset = {
			offset: [rect.x / this.size, rect.y / this.size],
			scale: [rect.width / this.size, rect.height / this.size]
		};

		this.uvOffsets.set(id, uvOffset);
		return uvOffset;
	}

	getUVTransform(id) {
		return this.uvOffsets.get(id);
	}
}

Culling Techniques

Frustum Culling

Don't render objects outside the camera view:

class FrustumCuller {
	constructor() {
		this.planes = new Array(6); // 6 frustum planes
	}

	updateFromCamera(camera) {
		const vp = camera.viewProjectionMatrix;

		// Extract frustum planes from VP matrix
		// Left, Right, Bottom, Top, Near, Far
		this.planes[0] = extractPlane(vp, 0); // Left
		this.planes[1] = extractPlane(vp, 1); // Right
		this.planes[2] = extractPlane(vp, 2); // Bottom
		this.planes[3] = extractPlane(vp, 3); // Top
		this.planes[4] = extractPlane(vp, 4); // Near
		this.planes[5] = extractPlane(vp, 5); // Far
	}

	isVisible(boundingSphere) {
		for (const plane of this.planes) {
			const distance = dot(plane.normal, boundingSphere.center) + plane.distance;

			if (distance < -boundingSphere.radius) {
				return false; // Completely outside
			}
		}

		return true; // Inside or intersecting
	}

	cullObjects(objects) {
		return objects.filter((obj) => this.isVisible(obj.bounds));
	}
}

Occlusion Culling

Don't render objects behind other objects:

// Hardware occlusion queries
class OcclusionCuller {
	constructor(device) {
		this.querySet = device.createQuerySet({
			type: 'occlusion',
			count: 1000 // Max queries per frame
		});

		this.queryBuffer = device.createBuffer({
			size: 8 * 1000,
			usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC
		});

		this.resultBuffer = device.createBuffer({
			size: 8 * 1000,
			usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
		});
	}

	// Render bounding boxes as occlusion queries
	testOcclusion(passEncoder, objects) {
		for (let i = 0; i < objects.length; i++) {
			passEncoder.beginOcclusionQuery(i);

			// Render bounding box (simplified geometry)
			passEncoder.draw(objects[i].boundingBoxVertexCount);

			passEncoder.endOcclusionQuery();
		}
	}

	// Get query results
	async getResults(device) {
		// Resolve queries
		const encoder = device.createCommandEncoder();
		encoder.resolveQuerySet(this.querySet, 0, 1000, this.queryBuffer, 0);
		encoder.copyBufferToBuffer(this.queryBuffer, 0, this.resultBuffer, 0, 8 * 1000);
		device.queue.submit([encoder.finish()]);

		// Read results
		await this.resultBuffer.mapAsync(GPUMapMode.READ);
		const results = new BigUint64Array(this.resultBuffer.getMappedRange());

		// Results indicate pixel count passed depth test
		const visibleObjects = [];
		for (let i = 0; i < results.length; i++) {
			if (results[i] > 0n) {
				visibleObjects.push(i);
			}
		}

		this.resultBuffer.unmap();
		return visibleObjects;
	}
}

Level of Detail (LOD)

Dynamic LOD System

class LODManager {
	constructor() {
		this.lodLevels = [
			{ distance: 10, meshIndex: 0 }, // High detail
			{ distance: 50, meshIndex: 1 }, // Medium detail
			{ distance: 100, meshIndex: 2 }, // Low detail
			{ distance: 200, meshIndex: 3 } // Very low detail
		];
	}

	selectLOD(object, cameraPosition) {
		const distance = vec3.distance(object.position, cameraPosition);

		for (let i = this.lodLevels.length - 1; i >= 0; i--) {
			if (distance >= this.lodLevels[i].distance) {
				return this.lodLevels[i].meshIndex;
			}
		}

		return 0; // Highest detail
	}

	render(passEncoder, objects, cameraPosition) {
		// Group objects by LOD level
		const lodGroups = new Map();

		for (const object of objects) {
			const lodLevel = this.selectLOD(object, cameraPosition);

			if (!lodGroups.has(lodLevel)) {
				lodGroups.set(lodLevel, []);
			}

			lodGroups.get(lodLevel).push(object);
		}

		// Render each LOD group
		for (const [lodLevel, group] of lodGroups) {
			passEncoder.setPipeline(this.pipelines[lodLevel]);

			for (const object of group) {
				// Render object at appropriate LOD
				passEncoder.draw(object.lodMeshes[lodLevel].vertexCount);
			}
		}
	}
}

Next Steps

Profiling & Monitoring

Tools and techniques for identifying bottlenecks

Profiling & Monitoring

Master the art of identifying and fixing performance bottlenecks with comprehensive profiling tools and techniques.

Why Profile?

"Premature optimization is the root of all evil" - Donald Knuth

Always profile before optimizing:

  1. Identifies real bottlenecks (not guesses)
  2. Validates optimization impact
  3. Prevents wasted effort
  4. Provides measurable progress

Profiling Workflow

1. Establish Baseline
   ↓
2. Identify Bottleneck
   ↓
3. Hypothesize Solution
   ↓
4. Implement Fix
   ↓
5. Measure Impact
   ↓
6. Repeat

Quick Performance Checks

Frame Time Analysis

Understanding frame time breakdown:

Target: 60 FPS = 16.67ms per frame

Breakdown:
├─ Game Logic:    3ms (18%)
├─ Rendering:     10ms (60%)
├─ Physics:       2ms (12%)
├─ Audio:         0.5ms (3%)
└─ Other:         1.17ms (7%)

Common Performance Metrics

Metric Excellent Good Poor Critical
FPS 120+ 60+ 30+ <30
Frame Time <8ms <16ms <33ms >33ms
Draw Calls <1000 <3000 <5000 >5000
Triangles <1M <3M <5M >5M
Memory <4GB <6GB <8GB >8GB

Browser Profiling Tools

Chrome DevTools

Performance Tab:

// Mark performance regions
performance.mark('render-start');

// Your rendering code
renderScene();

performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');

// Get measurements
const measures = performance.getEntriesByType('measure');
console.log(`Render time: ${measures[0].duration}ms`);

GPU Profiling:

  1. Open DevTools (F12)
  2. Performance Tab → Record
  3. Interact with application
  4. Stop recording
  5. Analyze "GPU" section in flame chart

Memory Profiling:

// Take heap snapshots
console.profile('Memory Test');

// Your code
loadAssets();

console.profileEnd('Memory Test');

Firefox Profiler

Built-in Performance Tool:

  1. Open Profiler: about:profiling
  2. Enable WebGPU features
  3. Start Recording
  4. Perform actions
  5. Analyze in profiler.firefox.com

WebGPU-Specific Profiling

// Timestamp queries
const querySet = device.createQuerySet({
	type: 'timestamp',
	count: 2
});

const resolveBuffer = device.createBuffer({
	size: 16,
	usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC
});

const resultBuffer = device.createBuffer({
	size: 16,
	usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});

// Record timestamps
const encoder = device.createCommandEncoder();
encoder.writeTimestamp(querySet, 0);

// Your rendering commands
const pass = encoder.beginRenderPass(/*...*/);
// ... rendering ...
pass.end();

encoder.writeTimestamp(querySet, 1);

// Resolve and read
encoder.resolveQuerySet(querySet, 0, 2, resolveBuffer, 0);
encoder.copyBufferToBuffer(resolveBuffer, 0, resultBuffer, 0, 16);
device.queue.submit([encoder.finish()]);

// Read results
await resultBuffer.mapAsync(GPUMapMode.READ);
const times = new BigUint64Array(resultBuffer.getMappedRange());
const duration = Number(times[1] - times[0]) / 1000000; // Convert to ms
console.log(`GPU time: ${duration}ms`);
resultBuffer.unmap();

Game Engine Profiling

Unreal Engine 5

Built-in Console Commands

// Frame time breakdown
stat fps          // Show FPS
stat unit         // Show frame time breakdown
stat game         // Game thread time
stat gpu          // GPU time
stat scenerendering // Detailed rendering stats
stat memory       // Memory usage
stat streaming    // Asset streaming stats

Unreal Insights

Capture a trace:

// In code
TRACE_BOOKMARK(TEXT("Important Event"));

// Or console command
trace.start
trace.stop

// Or command line
MyGame.exe -trace=cpu,gpu,frame

Analyze in Unreal Insights:

  1. Open Unreal Insights
  2. Load trace file (.utrace)
  3. Navigate to Timing view
  4. Analyze frame data, GPU, CPU timings

GPU Visualizer

Console: ProfileGPU
Or: Ctrl + Shift + ,

Shows detailed GPU timing for each rendering pass:

BasePass          15.2ms
  Depth Prepass   2.1ms
  Opaque          10.3ms
  Translucent     2.8ms
Lighting          8.5ms
  Shadows         5.2ms
  Direct Lighting 2.1ms
  Sky             1.2ms
Post Processing   3.2ms
  Bloom           0.8ms
  Tone Mapping    0.5ms
  TAA             1.9ms

Session Frontend

Advanced profiling:

  1. Window → Developer Tools → Session Frontend
  2. Profiler Tab
  3. Capture session
  4. Analyze detailed metrics

Unity Profiler

Profiler Window

Window → Analysis → Profiler

Key Modules:

  • CPU Usage
  • GPU Usage
  • Rendering
  • Memory
  • Audio
  • Physics
  • UI

Frame Debugger

Window → Analysis → Frame Debugger

Step through each draw call:

Frame 1245
├─ Clear (RT: 0)
├─ Draw Skybox
├─ Draw Opaque
│   ├─ Draw Mesh (Material: Standard)
│   ├─ Draw Mesh (Material: Standard)
│   └─ Draw Mesh (Material: Custom)
├─ Draw Transparent
└─ Post Processing
    ├─ Bloom
    ├─ Color Grading
    └─ TAA

Memory Profiler

Window → Analysis → Memory Profiler

Features:

  • Heap snapshots
  • Memory allocation tracking
  • Native memory analysis
  • Compare snapshots

Godot Engine

Performance Monitors

# Enable performance overlay
Performance.set_monitoring_enabled(true)

# Get specific metrics
var fps = Performance.get_monitor(Performance.TIME_FPS)
var memory = Performance.get_monitor(Performance.MEMORY_STATIC)
var draw_calls = Performance.get_monitor(Performance.RENDER_DRAW_CALLS_IN_FRAME)

print("FPS: ", fps)
print("Memory: ", memory / 1024 / 1024, " MB")
print("Draw Calls: ", draw_calls)

Visual Profiler

Debug → Profiler

External Profiling Tools

RenderDoc

GPU debugging and profiling:

Capture a frame:

  1. Launch RenderDoc
  2. Point to your executable
  3. Launch and inject
  4. Press F12 to capture frame
  5. Analyze in RenderDoc

What you can see:

  • All draw calls
  • Every texture
  • Every shader
  • GPU timings
  • Resource usage

NVIDIA Nsight Graphics

Features:

  • GPU trace capture
  • Shader profiling
  • Ray tracing analysis
  • Memory analysis

Basic workflow:

# Launch with Nsight
nsight-graphics.exe YourApp.exe

# Or connect to running app
nsight-graphics --attach <PID>

AMD Radeon GPU Profiler (RGP)

Capture:

# Start profiling server
RadeonDeveloperPanel.exe

# In your app
# Press Shift+Ctrl+C to capture

Analysis:

  • Wavefront occupancy
  • Event timing
  • Pipeline stages
  • Bottleneck identification

Intel GPA

Features:

  • Frame analysis
  • Platform analysis
  • System analysis
  • GPU metrics

Tracy Profiler

High-performance C++ profiler:

Instrumentation:

#include <Tracy.hpp>

void Update() {
    ZoneScoped; // Automatic profiling

    {
        ZoneScopedN("Physics"); // Named scope
        UpdatePhysics();
    }

    {
        ZoneScopedN("Rendering");
        Render();
    }
}

void Render() {
    ZoneScoped;

    TracyGpuZone("GPU Render"); // GPU profiling
    // Rendering code
}

Capture:

# Run profiler server
Tracy.exe

# Run your app with Tracy enabled
./YourApp

Custom Profiling

Build Your Own Profiler

class Profiler {
	private samples: Map<string, number[]> = new Map();
	private startTimes: Map<string, number> = new Map();

	begin(name: string) {
		this.startTimes.set(name, performance.now());
	}

	end(name: string) {
		const startTime = this.startTimes.get(name);
		if (!startTime) return;

		const duration = performance.now() - startTime;

		if (!this.samples.has(name)) {
			this.samples.set(name, []);
		}

		const samples = this.samples.get(name)!;
		samples.push(duration);

		// Keep last 60 samples
		if (samples.length > 60) {
			samples.shift();
		}

		this.startTimes.delete(name);
	}

	getStats(name: string) {
		const samples = this.samples.get(name);
		if (!samples || samples.length === 0) {
			return null;
		}

		const sorted = [...samples].sort((a, b) => a - b);
		const sum = samples.reduce((a, b) => a + b, 0);

		return {
			avg: sum / samples.length,
			min: sorted[0],
			max: sorted[sorted.length - 1],
			p50: sorted[Math.floor(sorted.length * 0.5)],
			p95: sorted[Math.floor(sorted.length * 0.95)],
			p99: sorted[Math.floor(sorted.length * 0.99)]
		};
	}

	report() {
		console.log('\n=== Performance Report ===');
		for (const [name, _] of this.samples) {
			const stats = this.getStats(name);
			if (stats) {
				console.log(`\n${name}:`);
				console.log(`  Average: ${stats.avg.toFixed(2)}ms`);
				console.log(`  P50:     ${stats.p50.toFixed(2)}ms`);
				console.log(`  P95:     ${stats.p95.toFixed(2)}ms`);
				console.log(`  P99:     ${stats.p99.toFixed(2)}ms`);
				console.log(`  Min:     ${stats.min.toFixed(2)}ms`);
				console.log(`  Max:     ${stats.max.toFixed(2)}ms`);
			}
		}
	}
}

// Usage
const profiler = new Profiler();

function gameLoop() {
	profiler.begin('Frame');

	profiler.begin('Update');
	update();
	profiler.end('Update');

	profiler.begin('Render');
	render();
	profiler.end('Render');

	profiler.end('Frame');

	requestAnimationFrame(gameLoop);
}

// Print report every 5 seconds
setInterval(() => profiler.report(), 5000);

Scoped Profiling

class ScopedTimer {
	constructor(
		private profiler: Profiler,
		private name: string
	) {
		this.profiler.begin(this.name);
	}

	end() {
		this.profiler.end(this.name);
	}
}

// Usage with RAII pattern
function render() {
	const timer = new ScopedTimer(profiler, 'Render');

	// Rendering code
	drawScene();

	timer.end();
}

// Or with automatic cleanup
function scopedProfile<T>(profiler: Profiler, name: string, fn: () => T): T {
	profiler.begin(name);
	try {
		return fn();
	} finally {
		profiler.end(name);
	}
}

// Usage
scopedProfile(profiler, 'Physics', () => {
	updatePhysics();
});

Performance Monitoring in Production

Real-Time Monitoring

class PerformanceMonitor {
	private metrics: {
		fps: number[];
		frameTime: number[];
		memory: number[];
	} = {
		fps: [],
		frameTime: [],
		memory: []
	};

	private lastTime = performance.now();
	private frameCount = 0;

	update() {
		const now = performance.now();
		const deltaTime = now - this.lastTime;

		this.frameCount++;

		// Update FPS every second
		if (deltaTime >= 1000) {
			const fps = (this.frameCount / deltaTime) * 1000;

			this.metrics.fps.push(fps);
			this.metrics.frameTime.push(deltaTime / this.frameCount);

			// Get memory usage
			if (performance.memory) {
				const memoryMB = performance.memory.usedJSHeapSize / 1024 / 1024;
				this.metrics.memory.push(memoryMB);
			}

			// Keep last 60 seconds
			const maxSamples = 60;
			if (this.metrics.fps.length > maxSamples) {
				this.metrics.fps.shift();
				this.metrics.frameTime.shift();
				this.metrics.memory.shift();
			}

			this.frameCount = 0;
			this.lastTime = now;

			// Send to analytics
			this.sendMetrics();
		}
	}

	private sendMetrics() {
		const avg = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length;

		const data = {
			avgFps: avg(this.metrics.fps),
			avgFrameTime: avg(this.metrics.frameTime),
			avgMemory: avg(this.metrics.memory),
			minFps: Math.min(...this.metrics.fps),
			timestamp: Date.now()
		};

		// Send to analytics service
		fetch('/api/metrics', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(data)
		}).catch(console.error);
	}

	getStats() {
		return {
			currentFPS: this.metrics.fps[this.metrics.fps.length - 1] || 0,
			averageFPS: this.metrics.fps.reduce((a, b) => a + b, 0) / this.metrics.fps.length || 0,
			currentMemory: this.metrics.memory[this.metrics.memory.length - 1] || 0
		};
	}
}

Performance Budgets

class PerformanceBudget {
	private budgets = {
		frameTime: 16.67, // 60 FPS
		drawCalls: 2000,
		memory: 4096, // MB
		textureMemory: 2048 // MB
	};

	private violations: string[] = [];

	check(metrics: { frameTime: number; drawCalls: number; memory: number; textureMemory: number }) {
		this.violations = [];

		if (metrics.frameTime > this.budgets.frameTime) {
			this.violations.push(
				`Frame time budget exceeded: ${metrics.frameTime.toFixed(2)}ms > ${this.budgets.frameTime}ms`
			);
		}

		if (metrics.drawCalls > this.budgets.drawCalls) {
			this.violations.push(
				`Draw call budget exceeded: ${metrics.drawCalls} > ${this.budgets.drawCalls}`
			);
		}

		if (metrics.memory > this.budgets.memory) {
			this.violations.push(
				`Memory budget exceeded: ${metrics.memory}MB > ${this.budgets.memory}MB`
			);
		}

		if (metrics.textureMemory > this.budgets.textureMemory) {
			this.violations.push(
				`Texture memory budget exceeded: ${metrics.textureMemory}MB > ${this.budgets.textureMemory}MB`
			);
		}

		if (this.violations.length > 0) {
			console.warn('Performance budget violations:', this.violations);

			// Alert team in development
			if (process.env.NODE_ENV === 'development') {
				this.alertDevelopers();
			}
		}

		return this.violations.length === 0;
	}

	private alertDevelopers() {
		// Send alert to development team
		const message = `Performance Budget Violations:\n${this.violations.join('\n')}`;
		console.error(message);
	}
}

Profiling Best Practices

1. Profile on Target Hardware

Don't only profile on high-end development machines:

Development:    RTX 4090, 32GB RAM → 200+ FPS
Target High:    RTX 3060, 16GB RAM → 120 FPS
Target Medium:  GTX 1060, 8GB RAM  → 60 FPS
Target Low:     GTX 970, 6GB RAM   → 30 FPS

2. Profile Representative Scenarios

Test realistic conditions:

  • Average player count, not empty scenes
  • Typical asset loads, not minimal test levels
  • Real network conditions
  • Sustained play sessions (30+ minutes)

3. Statistical Significance

Collect enough data:

// Bad: Single measurement
const time = measureOnce();

// Good: Average of many measurements
const times = [];
for (let i = 0; i < 100; i++) {
	times.push(measure());
}
const avgTime = times.reduce((a, b) => a + b) / times.length;

4. Isolate Changes

Test one optimization at a time:

Baseline → Optimization A → Measure → Revert
Baseline → Optimization B → Measure → Revert
Baseline → Best optimization → Keep

Next Steps

Unreal Engine 5 Optimization

UE5-specific optimization including Nanite and Lumen

Unreal Engine 5 Optimization

Comprehensive optimization guide for Unreal Engine 5 streaming applications, covering rendering, gameplay, and engine-specific optimizations.

UE5-Specific Performance Features

Nanite Virtualized Geometry

Nanite automatically manages geometric complexity:

When to Use Nanite:

  • ✅ Static meshes with high poly counts (>100K triangles)
  • ✅ Detailed environmental assets
  • ✅ Architectural visualization
  • ✅ Film-quality assets

When NOT to Use Nanite:

  • ❌ Skeletal meshes (characters)
  • ❌ Translucent materials
  • ❌ World Position Offset materials
  • ❌ Deforming meshes

Enable Nanite:

// In Static Mesh Editor
Details → Nanite Settings
✓ Enable Nanite Support

// Or via console
r.Nanite 1

Nanite Optimization:

// Project Settings → Engine → Rendering
r.Nanite.MaxPixelsPerEdge 1  // Higher = more detail
r.Nanite.MaxNodes 1024       // Higher = better streaming

// Debug visualization
r.Nanite.Visualize Overview  // Show Nanite coverage
r.Nanite.Visualize Triangles // Show triangle density

Lumen Global Illumination

Dynamic global illumination without baking:

Configuration:

// Project Settings → Engine → Rendering
Dynamic Global Illumination Method: Lumen
Reflection Method: Lumen

// Console variables
r.Lumen.DiffuseColorBoost 1.0
r.Lumen.FinalGather.ScreenTraces 1
r.Lumen.Reflections.ScreenTraces 1

// Quality settings
r.LumenScene.SurfaceCache.MeshCardsResolution 1024  // Higher = better quality
r.LumenScene.RadianceCache.SpatialFilterProbeRadius 2.0  // Blur radius

Optimization Tips:

// Reduce Lumen quality for better performance
r.Lumen.TraceMeshSDFs 0              // Disable SDF tracing
r.Lumen.HardwareRayTracing 0         // Disable HW ray tracing
r.LumenScene.SurfaceCache.MeshCardsResolution 512  // Lower resolution

// Quality vs Performance presets
// Ultra:   Resolution 2048, Full screen traces
// High:    Resolution 1024, Screen traces
// Medium:  Resolution 512, Reduced traces
// Low:     Disable Lumen, use traditional lighting

Virtual Shadow Maps (VSM)

Next-gen shadow system:

Enable VSM:

// Project Settings → Engine → Rendering
Shadow Map Method: Virtual Shadow Maps

// Or console
r.Shadow.Virtual.Enable 1

Optimization:

// Adjust quality
r.Shadow.Virtual.ResolutionLodBiasDirectional -1  // Sharper shadows
r.Shadow.Virtual.SMRT.RayCountDirectional 16      // Sample count

// Performance settings
r.Shadow.Virtual.MaxPhysicalPages 4096  // Memory budget
r.Shadow.Virtual.Cache 1                 // Enable caching

Rendering Optimization

Draw Call Reduction

Merge Actors:

// Select static meshes in level
Right-click → Merge Actors

Settings:
- Merge Physics: ✓
- Generate Lightmap UVs: ✓
- Replace Source Actors: ✓

Instanced Static Mesh (ISM):

// Use ISMC for repeated assets
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
UInstancedStaticMeshComponent* ISM;

void AMyActor::BeginPlay()
{
    Super::BeginPlay();

    ISM = CreateDefaultSubobject<UInstancedStaticMeshComponent>(TEXT("ISM"));
    ISM->SetStaticMesh(MyMesh);

    // Add many instances
    for (int32 i = 0; i < 1000; i++)
    {
        FTransform Transform(
            FRotator::ZeroRotator,
            FVector(i * 100.0f, 0, 0),
            FVector::OneVector
        );
        ISM->AddInstance(Transform);
    }
}

Hierarchical Instanced Static Mesh (HISM):

// Better for large counts with culling
UPROPERTY(VisibleAnywhere)
UHierarchicalInstancedStaticMeshComponent* HISM;

void AMyActor::SpawnInstances()
{
    HISM = CreateDefaultSubobject<UHierarchicalInstancedStaticMeshComponent>(TEXT("HISM"));

    // HISM automatically handles LOD and culling
    for (const FVector& Location : SpawnLocations)
    {
        HISM->AddInstance(FTransform(Location));
    }

    // Build tree for efficient culling
    HISM->BuildTreeIfOutdated(true, false);
}

Material Optimization

Shader Complexity:

// View shader complexity
View Modes → Optimization Viewmodes → Shader Complexity

// Target instruction counts:
// Simple:      <100 instructions
// Medium:      100-200 instructions
// Complex:     200-400 instructions
// Very Complex: >400 instructions (avoid!)

Material Best Practices:

// ✅ Good: Static switches
[Static Switch Parameter] UseDiffuseTexture

if (UseDiffuseTexture)
{
    // Texture sampling
}
else
{
    // Solid color
}

// ❌ Bad: Dynamic branches (runtime cost)
if (RuntimeValue > 0.5)  // Dynamic branch
{
    // Expensive path
}

Reduce Material Samples:

// ❌ Bad: Multiple texture samples
float3 Albedo = Texture2DSample(AlbedoTex, Sampler, UV);
float3 Normal = Texture2DSample(NormalTex, Sampler, UV);
float Roughness = Texture2DSample(RoughnessTex, Sampler, UV);
float Metallic = Texture2DSample(MetallicTex, Sampler, UV);

// ✅ Good: Packed textures
float4 PackedMaterial = Texture2DSample(PackedTex, Sampler, UV);
float3 Albedo = PackedMaterial.rgb;
float Roughness = PackedMaterial.a;

Material Layers:

Use material layers for reusability without cost:

// Create Material Layer
Content Browser → Right-click → Materials & Textures → Material Layer

// Use in material
Material Editor → Add Material Attributes → Layer Blend

LOD Configuration

Auto LOD Generation:

// Static Mesh Editor → LOD Settings
Number of LODs: 4

LOD 0: 100% triangles (0-10m)
LOD 1: 50% triangles (10-50m)
LOD 2: 25% triangles (50-100m)
LOD 3: 10% triangles (100m+)

// Automatic LOD generation
LOD Settings → Auto Compute LOD Distances: ✓

HLOD (Hierarchical LOD):

// Project Settings → Engine → LOD System
Hierarchical LODSetup → Add HLOD Level

HLOD 0: Transition Screen Size 0.3 (far)
HLOD 1: Transition Screen Size 0.5 (medium)
HLOD 2: Transition Screen Size 0.7 (near)

// Build HLODs
World Settings → LODSystem → Build HLODs

Culling Optimization

Occlusion Culling:

// Project Settings → Engine → Rendering
Occlusion Culling: ✓
Support Software Occlusion Culling: ✓

// Visualize occlusion
r.VisualizeOccludedPrimitives 1

// Adjust settings
r.HZBOcclusion 1  // Hierarchical Z-Buffer
r.HZBOcclusion.MaxPrimitives 20000

Precomputed Visibility:

// Add Precomputed Visibility Volume to level
Place Actors → Volumes → Precomputed Visibility Volume

// Resize to cover playable area
Build → Build → Build Lighting Only

// Enable in World Settings
World Settings → Precomputed Visibility → ✓ Use for Occlusion

Distance Culling:

// Per-actor culling
Actor → Rendering → Max Draw Distance
Desired Max Draw Distance: 5000

// Or in code
StaticMeshComponent->SetCullDistance(5000.0f);

// Cull Distance Volumes
Place Actors → Volumes → Cull Distance Volume
Add size-based culling rules

Gameplay Optimization

Tick Optimization

Disable Tick When Possible:

// In BeginPlay
PrimaryActorTick.bCanEverTick = false;  // No tick at all

// Or variable tick
PrimaryActorTick.TickInterval = 0.1f;   // Tick every 0.1s

Tick Groups:

// Group related ticks
PrimaryActorTick.TickGroup = TG_PostPhysics;

// Available groups:
// TG_PrePhysics
// TG_DuringPhysics
// TG_PostPhysics
// TG_PostUpdateWork

Async Tick:

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()

public:
    virtual void Tick(float DeltaTime) override
    {
        // Move expensive work to async task
        FGraphEventRef Task = FFunctionGraphTask::CreateAndDispatchWhenReady(
            [this]()
            {
                // Heavy computation
                DoExpensiveWork();
            },
            TStatId(),
            nullptr,
            ENamedThreads::AnyBackgroundThreadNormalTask
        );
    }

private:
    void DoExpensiveWork()
    {
        // This runs on background thread
    }
};

Blueprint Optimization

Convert to C++:

Critical gameplay code should be in C++:

// ❌ Blueprint: ~10-20x slower
// BeginPlay:
//   - Loop 1000 times
//   - Do Math Operations

// ✅ C++: Fast
void AMyActor::BeginPlay()
{
    Super::BeginPlay();

    for (int32 i = 0; i < 1000; i++)
    {
        // Math operations
        float Result = FMath::Sin(i) * FMath::Cos(i);
    }
}

Nativize Blueprints:

// Project Settings → Packaging
Blueprint Nativization Method: Inclusive

// Or exclusive with specific blueprints
Blueprint Nativization Method: Exclusive
Blueprints to Nativize: BP_MyActor, BP_MyComponent

Blueprint Best Practices:

// ✅ Use pure functions (no execution pins)
Pure Function: Get Player Location

// ❌ Avoid per-tick execution
Event Tick → Do Work  // Bad!

// ✅ Use Timers instead
Event BeginPlay → Set Timer by Function Name (Looping, 0.1s)

// ✅ Macro for complex math
Macro Library → Complex Calculation (reusable)

// ❌ Don't use Cast in Tick
Event Tick → Cast To MyClass → Do Work  // Expensive!

// ✅ Cache cast result
Event BeginPlay → Cast To MyClass → Set MyClassRef
Event Tick → Use MyClassRef

Animation Optimization

LOD for Skeletal Meshes:

// Skeletal Mesh Editor → LOD Settings
LOD 0: Full skeleton (0-10m)
LOD 1: Reduced bones (10-30m)
LOD 2: Minimal bones (30m+)

// Disable animation beyond distance
Skeletal Mesh Component → Visualization
Enable Animation LOD: ✓

Update Rate Optimization:

// URO (Update Rate Optimization)
Skeletal Mesh Component → Optimization
Update Rate Optimization: ✓

// Configure URO
Skeletal Mesh LOD Settings → URO Settings
Base Update Rate: 15 (times per second)
Maximum Eval Rate For Interpolation: 30

Animation Budgets:

// Project Settings → Engine → Animation
Default Animation Update Rate: Optimized
Animation Budget Allocator: ✓

// Budget settings
Max Interpolation Frames to Skip: 3
Max Evaluation Frames to Skip: 5

Physics Optimization

Simplify Collision:

// Use simple collision shapes
Static Mesh Editor → Collision
Collision Complexity: Use Simple Collision As Complex

// Generate simple collision
Collision → Auto Convex Collision
Max Hulls: 4  // Lower = faster
Hull Precision: Medium

Physics Sub-Stepping:

// Project Settings → Engine → Physics
Substepping: ✓
Max Substep Delta Time: 0.0167 (60 Hz)
Max Substeps: 6

Async Physics:

// Enable async physics tick
Physics Settings → Simulation
Enable Async Physics Tick: ✓

// Component-level async
PhysicsComponent->bTickInMultithreadedContext = true;

Streaming and Loading

World Partition:

// Enable for open world
World Settings → World → Enable World Partition: ✓

// Configure streaming
World Settings → World Partition → Runtime Settings
Server Streaming Out Distance: 30000
Loading Range: 25600

Level Streaming:

// Load level asynchronously
void AMyGameMode::StreamLevel(FName LevelName)
{
    FLatentActionInfo LatentInfo;
    LatentInfo.CallbackTarget = this;
    LatentInfo.ExecutionFunction = TEXT("OnLevelLoaded");
    LatentInfo.Linkage = 0;
    LatentInfo.UUID = FMath::Rand();

    UGameplayStatics::LoadStreamLevel(
        this,
        LevelName,
        true,  // Make visible
        true,  // Block on load
        LatentInfo
    );
}

void AMyGameMode::OnLevelLoaded()
{
    UE_LOG(LogTemp, Log, TEXT("Level loaded"));
}

Texture Streaming:

// Enable virtual texturing
Project Settings → Rendering
Virtual Textures: ✓
Enable virtual texture support: ✓

// Texture streaming pool
r.Streaming.PoolSize 3000  // MB

// Boost texture streaming
r.Streaming.Boost 1.0

Memory Optimization

Asset Size Reduction:

// Texture compression
Texture Editor → Compression Settings
Compression: TC_Default (BC1/BC3)

// Or platform-specific
Platform-specific → iOS → ASTC 4x4
Platform-specific → Android → ASTC 4x4

// Mip-map generation
Mip Gen Settings: SimpleAverage
Never Stream: ✗ (allow streaming)

Memory Profiling:

// Console commands
memreport           // Detailed memory report
mem                 // Quick memory stats
obj list            // List all objects

// Memory profiler
Window → Developer Tools → Memory Insights

Garbage Collection:

// Force GC
GetWorld()->ForceGarbageCollection(true);

// Optimize GC
gc.TimeBetweenPurgingPendingKillObjects 60.0  // Seconds
gc.MaxObjectsInGame 2097152

// Incremental GC
gc.IncrementalBeginDestroyEnabled 1

Profiling Commands

Essential Console Commands:

// FPS and frame time
stat fps
stat unit         // Frame, Game, Draw, GPU time
stat unitgraph    // Visual graph

// Rendering
stat scenerendering
stat rhi          // RHI stats
stat gpu          // Detailed GPU timing

// Memory
stat memory
stat streaming    // Texture/mesh streaming

// Specific systems
stat game        // Game thread
stat ai          // AI systems
stat physics     // Physics simulation
stat slate       // UI rendering

Performance Capture:

// Start profiling
stat startfile
// Play game
stat stopfile

// Or use Unreal Insights
trace.start
// Play game
trace.stop

// Analyze in Unreal Insights

Next Steps

Resources