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:
GPU Bottlenecks (Most common for streaming)
- Rendering complexity
- Shader performance
- Texture memory
- Draw calls
CPU Bottlenecks
- Game logic
- Physics simulation
- AI processing
- Animation updates
Memory Issues
- Texture streaming
- Asset loading
- Memory leaks
- Garbage collection
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:
- Rendering Optimization - GPU, shaders, draw calls, WebGPU
- Profiling & Monitoring - Tools and techniques for finding bottlenecks
- Engine-Specific Optimization - UE5, Unity, Godot optimization guides
- Memory Optimization - Reduce memory usage and prevent leaks
Resources
Official Documentation:
Tools:
- RenderDoc - GPU debugging
- Tracy Profiler - CPU profiling
- Intel GPA
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:
- Vertex Shader Bound - Too many vertices
- Pixel Shader Bound - Complex shaders or high resolution
- Texture Bound - Too many or too large textures
- Bandwidth Bound - Memory transfer limitations
- 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 to identify bottlenecks
- Engine-Specific Optimization - UE5, Unity optimization
- Memory Optimization - Reduce memory usage
- Overview - General optimization principles
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:
- Identifies real bottlenecks (not guesses)
- Validates optimization impact
- Prevents wasted effort
- 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:
- Open DevTools (F12)
- Performance Tab → Record
- Interact with application
- Stop recording
- 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:
- Open Profiler:
about:profiling - Enable WebGPU features
- Start Recording
- Perform actions
- 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:
- Open Unreal Insights
- Load trace file (.utrace)
- Navigate to Timing view
- 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:
- Window → Developer Tools → Session Frontend
- Profiler Tab
- Capture session
- 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:
- Launch RenderDoc
- Point to your executable
- Launch and inject
- Press F12 to capture frame
- 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
- Rendering Optimization - Optimize GPU performance
- Engine-Specific Optimization - UE5, Unity guides
- Overview - General optimization principles
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
- Rendering Optimization - WebGPU and rendering details
- Profiling & Monitoring - Tools and techniques
- Overview - General optimization principles
- Unity Builds - Unity-specific optimization