Skip to main content

Arena Rendering Optimizations

In February 2026, the duel arena rendering system was completely overhauled to eliminate performance bottlenecks. This document explains the optimizations and their impact.

Performance Improvements

Before vs After

MetricBeforeAfterImprovement
Draw Calls~846~2297% reduction
PointLights280100% reduction
CPU per frameHigh (light updates)Minimal~80% reduction
GPU efficiencyLow (state changes)High (instancing)Significant

Key Changes

  1. InstancedMesh Conversion - 846 individual meshes → 20 instanced draw calls
  2. PointLight Removal - 28 CPU-animated lights → GPU-driven TSL emissive materials
  3. Fire Particle Rewrite - Enhanced shader with value noise and turbulent motion
  4. Dead Code Removal - Unused functions deleted (createArenaMarker, createAmbientDust, createLobbyBenches)

InstancedMesh Architecture

What is InstancedMesh?

InstancedMesh renders many copies of the same geometry with a single draw call. Each instance can have a unique position, rotation, and scale. Benefits:
  • Fewer draw calls - GPU state changes are expensive
  • Shared geometry - One buffer for all instances
  • Shared material - One shader compilation
  • GPU-friendly - Instancing is a native GPU feature

Arena Instancing Breakdown

ComponentCountInstancesDraw Calls
Fence Posts2881 InstancedMesh1
Fence Caps2881 InstancedMesh1
Fence Rails (X)361 InstancedMesh1
Fence Rails (Z)361 InstancedMesh1
Pillar Bases321 InstancedMesh1
Pillar Shafts321 InstancedMesh1
Pillar Capitals321 InstancedMesh1
Brazier Bowls241 InstancedMesh1
Border Strips (N/S)121 InstancedMesh1
Border Strips (E/W)121 InstancedMesh1
Banner Poles121 InstancedMesh1
Arena Floors6Individual meshes6
Forfeit Pillars12Individual meshes12
Banner Cloths12Individual meshes (3 materials)3
Lobby/Hospital2Individual meshes2
Total~84611 InstancedMesh + 32 individual~22

Why Some Meshes Aren’t Instanced

Arena Floors (6 individual meshes):
  • Need unique userData.arenaId for raycasting
  • Need layer 0+2 for click-to-move and minimap
  • Sharing one geometry + material is still efficient
Forfeit Pillars (12 individual meshes):
  • Need unique userData.entityId for interaction system
  • Each pillar is a clickable entity
Banner Cloths (12 individual meshes):
  • Only 3 unique colors (4 meshes per material)
  • Already batched by material

TSL Emissive Materials

Replacing PointLights

Problem: 28 PointLights forced expensive per-pixel lighting calculations every frame. Solution: GPU-driven TSL emissive materials with animated flicker.

Brazier Glow Material

Implementation: packages/shared/src/systems/client/DuelArenaVisualsSystem.ts
// TSL emissive node with per-instance phase offset
mat.emissiveNode = Fn(() => {
  const wp = positionWorld;
  // Quantize world position so all vertices of one brazier share phase
  const quantized = vec2(tslFloor(wp.x.add(0.5)), tslFloor(wp.z.add(0.5)));
  const phase = tslHash(quantized).mul(6.28);
  
  // Multi-frequency sine flicker + high-freq noise
  const flicker = sin(t.mul(10.0).add(phase))
    .mul(0.15)
    .add(sin(t.mul(7.3).add(phase.mul(1.7))).mul(0.08));
  const noise = fract(sin(t.mul(43.7).add(phase)).mul(9827.3)).mul(0.05);
  const intensity = float(0.6).add(flicker).add(noise);
  
  // Only top face glows (fire opening)
  const topMask = smoothstep(float(0.7), float(0.95), normalWorld.y);
  
  return vec3(1.0, 0.4, 0.0).mul(intensity).mul(topMask);
})();
Key Features:
  • Per-instance phase: Each brazier flickers independently
  • GPU-driven: Zero CPU cost per frame
  • Realistic flicker: Multi-frequency sine + noise matches old PointLight behavior
  • Directional glow: Only top face emits light (fire opening)

Performance Impact

Before (28 PointLights):
  • CPU: Update 28 light intensities every frame
  • GPU: 28 per-pixel lighting passes on surrounding geometry
  • Memory: 28 light objects + shadow maps
After (TSL emissive):
  • CPU: Update one time uniform per frame
  • GPU: Emissive calculation in fragment shader (already running)
  • Memory: One material + one uniform
Result: ~80% CPU reduction, no per-pixel lighting overhead.

Fire Particle Shader Rewrite

Enhanced Fire Preset

The fire particle preset was rewritten with a new fragment shader for better visual quality and additive blending. Old Shader (simple radial glow):
float dist = length(uv - 0.5) * 2.0;
float glow = pow(max(1.0 - dist, 0.0), sharpness);
color = particleColor * glow;
alpha = glow;
New Shader (value noise + soft falloff):
// Smooth value noise via bilinear interpolation
float noise = valueNoise(uv * 4.0 + scrollY);

// Soft radial falloff (no hard edges)
float radialDist = length(uv - 0.5) * 2.0;
float softFalloff = pow(max(1.0 - radialDist, 0.0), 0.8);

// Noise modulates mask
float glow = softFalloff * (0.7 + noise * 0.3);

// Color gradient (bright core → particle color at edges)
vec3 coreColor = vec3(1.0, 0.9, 0.4);
vec3 fireColor = mix(particleColor, coreColor, coreness);

color = fireColor * glow * 1.5;
alpha = glow * opacity;
Improvements:
  • Soft falloff: No hard edges, particles blend smoothly
  • Value noise: Organic flame shapes (not uniform circles)
  • Scrolling noise: Upward motion feel
  • Color gradient: Bright white-yellow core → orange-red tips
  • Additive-friendly: Designed for overlapping particles to merge

Turbulent Vertex Motion

Particles now have per-particle turbulent motion for natural flame flickering:
// Flame-like turbulence — visible flicker that fades with height
const turbAmp = mul(float(0.04), sub(float(1.0), mul(t, float(0.7))));
const turbX = mul(sin(add(mul(time, float(7.0)), phase)), turbAmp);
const turbZ = mul(cos(add(mul(time, float(5.5)), mul(phase, float(1.5)))), turbAmp);

particleCenter = add(
  aEmitterPos,
  vec3(add(spreadX, turbX), riseY, add(spreadZ, turbZ))
);
Effect: Particles jitter and sway like real flames, not just rising straight up.

Torch Preset Removed

The torch preset was removed and unified with the enhanced fire preset. Migration:
// Before
particleSystem.register('torch_id', {
  type: 'glow',
  preset: 'torch',
  position: { x, y, z }
});

// After
particleSystem.register('torch_id', {
  type: 'glow',
  preset: 'fire',  // Use fire preset
  position: { x, y, z }
});

TSL Procedural Materials

Sandstone Block Pattern

Arena fences use a GPU-computed sandstone block pattern with:
  • Running bond layout - Offset rows for realistic masonry
  • Per-block color variation - Warm sandstone range (0.62-0.72 R, 0.52-0.60 G, 0.38-0.46 B)
  • Mortar grooves - Dark earth brown (0.35, 0.28, 0.2)
  • Bevel effect - Blocks appear raised with edge darkening
  • Surface grain - Fine noise texture for stone detail
World-Space UVs: Uses positionWorld.xz for horizontal and positionWorld.y for vertical, ensuring seamless tiling on any wall orientation.

Floor Tile Pattern

Arena floors use a square flagstone pattern with:
  • 1.2m tiles - Large flagstones with thin grout lines
  • Per-tile color variation - Sand-earth range (0.68-0.80 R, 0.54-0.64 G, 0.36-0.44 B)
  • Grout lines - Dark grout (0.4, 0.32, 0.22)
  • Bevel effect - Subtle tile edge darkening
  • Surface grain - Noise texture for worn stone
World-Space UVs: Uses positionWorld.xz so each arena looks unique despite sharing the same material.

Code Structure

File Organization

Main File: packages/shared/src/systems/client/DuelArenaVisualsSystem.ts Key Methods:
  • createSharedMaterials() - Creates all TSL materials once
  • buildFenceInstances() - Builds fence InstancedMesh (posts, caps, rails)
  • buildPillarInstances() - Builds pillar InstancedMesh (bases, shafts, capitals)
  • buildBrazierInstances() - Builds brazier InstancedMesh with TSL glow
  • buildBorderInstances() - Builds floor border InstancedMesh
  • buildBannerPoleInstances() - Builds banner pole InstancedMesh
  • createArenaFloors() - Creates individual floor meshes (need unique userData)
  • createForfeitPillars() - Creates individual forfeit pillars (need unique entityId)
  • createBannerCloths() - Creates individual banner cloths (3 materials)

Material Caching

All materials are created once and shared:
private stoneFenceMat: MeshStandardNodeMaterial | null = null;
private arenaFloorMat: MeshStandardNodeMaterial | null = null;
private borderMat: MeshStandardNodeMaterial | null = null;
private pillarStoneMat: MeshStandardNodeMaterial | null = null;
private brazierGlowMat: MeshStandardNodeMaterial | null = null;
private forfeitPillarMat: MeshStandardNodeMaterial | null = null;
private bannerPoleMat: MeshStandardNodeMaterial | null = null;
private lobbyStandMat: MeshStandardNodeMaterial | null = null;
Benefits:
  • One shader compilation per material
  • Shared GPU resources
  • Easier cleanup (dispose once)

Performance Metrics

Draw Call Reduction

Before:
Arena 1: 141 meshes
Arena 2: 141 meshes
Arena 3: 141 meshes
Arena 4: 141 meshes
Arena 5: 141 meshes
Arena 6: 141 meshes
Total: 846 draw calls
After:
Fence Posts: 1 InstancedMesh (288 instances)
Fence Caps: 1 InstancedMesh (288 instances)
Fence Rails X: 1 InstancedMesh (36 instances)
Fence Rails Z: 1 InstancedMesh (36 instances)
Pillar Bases: 1 InstancedMesh (32 instances)
Pillar Shafts: 1 InstancedMesh (32 instances)
Pillar Capitals: 1 InstancedMesh (32 instances)
Braziers: 1 InstancedMesh (24 instances)
Border N/S: 1 InstancedMesh (12 instances)
Border E/W: 1 InstancedMesh (12 instances)
Banner Poles: 1 InstancedMesh (12 instances)
Arena Floors: 6 individual meshes
Forfeit Pillars: 12 individual meshes
Banner Cloths: 3 materials × 4 meshes = 12 meshes
Lobby/Hospital: 2 individual meshes
Total: ~22 draw calls

Lighting Overhead Elimination

Before (28 PointLights):
// CPU: Update every frame
for (let i = 0; i < 28; i++) {
  light.intensity = BASE + sin(time * 10 + i * 1.7) * 0.15 + random() * 0.05;
}

// GPU: Per-pixel lighting for each light
for each pixel in scene:
  for each of 28 lights:
    calculate distance, attenuation, diffuse, specular
    accumulate lighting contribution
After (TSL emissive):
// CPU: Update one uniform
timeUniform.value += deltaTime;

// GPU: Emissive calculation (already in fragment shader)
emissive = baseColor * flickerIntensity * topMask;
Result: No per-pixel lighting calculations, no CPU light updates.

Visual Quality Improvements

Fire Particles

Old Fire:
  • Simple radial glow (uniform circles)
  • Hard edges (visible particle boundaries)
  • Straight upward motion (no turbulence)
  • Uniform color (no gradient)
New Fire:
  • Value noise (organic flame shapes)
  • Soft falloff (smooth blending)
  • Turbulent motion (visible flicker and sway)
  • Color gradient (white-yellow core → orange-red tips)
  • Scrolling noise (upward motion feel)
Particle Count: Increased from 18 to 28 per emitter (more particles, zero CPU cost).

Brazier Glow

Old Braziers:
  • Static emissive material (no animation)
  • PointLight for glow (expensive)
  • Uniform brightness (no flicker)
New Braziers:
  • Animated TSL emissive (GPU-driven flicker)
  • No PointLight (zero lighting cost)
  • Multi-frequency flicker (realistic fire)
  • Per-instance phase offset (each brazier unique)

Implementation Guide

Creating InstancedMesh

// 1. Create geometry (shared by all instances)
const geometry = new THREE.BoxGeometry(width, height, depth);

// 2. Create material (shared by all instances)
const material = new MeshStandardNodeMaterial({ color: 0x8b7355 });

// 3. Create InstancedMesh
const instancedMesh = new THREE.InstancedMesh(
  geometry,
  material,
  instanceCount  // Max number of instances
);

// 4. Set instance transforms
const matrix = new THREE.Matrix4();
for (let i = 0; i < instanceCount; i++) {
  matrix.makeTranslation(x, y, z);
  instancedMesh.setMatrixAt(i, matrix);
}

// 5. Update instance matrix
instancedMesh.instanceMatrix.needsUpdate = true;

// 6. Add to scene
scene.add(instancedMesh);

Creating TSL Emissive Material

// 1. Create time uniform
const timeUniform = uniform(float(0));

// 2. Create material with emissive node
const material = new MeshStandardNodeMaterial({
  color: 0xff4400,
  roughness: 0.7
});

material.emissiveNode = Fn(() => {
  const t = timeUniform;
  const wp = positionWorld;
  
  // Per-instance phase from world position
  const phase = tslHash(vec2(tslFloor(wp.x), tslFloor(wp.z))).mul(6.28);
  
  // Animated flicker
  const flicker = sin(t.mul(10.0).add(phase)).mul(0.15);
  const intensity = float(0.6).add(flicker);
  
  // Only top face glows
  const topMask = smoothstep(float(0.7), float(0.95), normalWorld.y);
  
  return vec3(1.0, 0.4, 0.0).mul(intensity).mul(topMask);
})();

// 3. Update time uniform every frame
update(deltaTime) {
  timeUniform.value += deltaTime;
}

Migration Guide

Updating Existing Arena Code

If you have custom arena modifications, update them to use InstancedMesh: Before:
// Creating 100 fence posts individually
for (let i = 0; i < 100; i++) {
  const post = new THREE.Mesh(postGeometry, material);
  post.position.set(x + i * spacing, y, z);
  scene.add(post);
}
After:
// Creating 100 fence posts with InstancedMesh
const posts = new THREE.InstancedMesh(postGeometry, material, 100);
const matrix = new THREE.Matrix4();

for (let i = 0; i < 100; i++) {
  matrix.makeTranslation(x + i * spacing, y, z);
  posts.setMatrixAt(i, matrix);
}

posts.instanceMatrix.needsUpdate = true;
scene.add(posts);

Replacing PointLights with TSL Emissive

Before:
// PointLight with CPU animation
const light = new THREE.PointLight(0xff6600, 0.8, 6);
light.position.set(x, y, z);
scene.add(light);

// Update every frame
update(deltaTime) {
  light.intensity = 0.8 + Math.sin(time * 10) * 0.15;
}
After:
// TSL emissive material (GPU animation)
const timeUniform = uniform(float(0));

const material = new MeshStandardNodeMaterial({ color: 0xff4400 });
material.emissiveNode = Fn(() => {
  const flicker = sin(timeUniform.mul(10.0)).mul(0.15);
  return vec3(1.0, 0.4, 0.0).mul(float(0.8).add(flicker));
})();

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// Update time uniform
update(deltaTime) {
  timeUniform.value += deltaTime;
}

Debugging

Verify Instancing

// Check instance count
console.log(instancedMesh.count);  // Should match expected count

// Check if instances are visible
instancedMesh.instanceMatrix.needsUpdate = true;

// Verify transforms
const matrix = new THREE.Matrix4();
instancedMesh.getMatrixAt(0, matrix);
console.log(matrix.elements);

Verify TSL Emissive

// Check if emissive node is set
console.log(material.emissiveNode);  // Should not be null

// Check time uniform
console.log(timeUniform.value);  // Should increase every frame

// Force material update
material.needsUpdate = true;

Performance Profiling

// Chrome DevTools
// Performance tab → Record → Look for:
// - "Draw calls" (should be ~22)
// - "GPU time" (should be lower)
// - "CPU time" (should be lower)

// Three.js stats
const stats = new Stats();
document.body.appendChild(stats.dom);

// Check draw calls
console.log(renderer.info.render.calls);  // Should be ~22

See Also