Skip to content

Billboard System

This document covers the billboard rendering system for the Cold LOD tier, where distant environment objects are rendered as camera-facing 2D sprites instead of full 3D meshes. This significantly reduces draw calls and vertex processing for WebGL builds.

┌─────────────────────────────────────────────────────────────────┐
│ LOD DISTANCE TIERS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Player ● │
│ │ │
│ <50u │ HOT TIER │
│ │ • Full 3D mesh │
│ │ • Colliders enabled │
│ │ • Full material/shader │
│ ▼ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │ │
│ <300u │ WARM TIER (Current) │
│ │ • Full 3D mesh │
│ │ • Colliders DISABLED │
│ │ • Full material/shader │
│ ▼ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │ │
│ <400u │ COOL TIER (Planned) │
│ │ • Simplified mesh │
│ │ • No colliders │
│ │ • Reduced shader │
│ ▼ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │ │
│ 400u+ │ COLD TIER (Planned - Billboard) │
│ │ • 2D quad facing camera │
│ │ • Pre-baked texture │
│ │ • GPU instanced │
│ ▼ │
│ │
└─────────────────────────────────────────────────────────────────┘
ComponentStatusDescription
Hot/Warm LOD✅ CompleteCollider toggling at 50 units
BillboardRenderer🔲 PlannedCamera-facing sprite renderer
BillboardAtlas🔲 PlannedTexture atlas for batching
BillboardBaker🔲 PlannedEditor tool for pre-baking
Cool LOD (Simplified Mesh)🔲 PlannedReduced polygon meshes
Cold LOD (Billboard)🔲 PlannedFull billboard integration

Location: Assets/Scripts/Terrain/EnvironmentObjectLOD.cs

public enum LODTier
{
Hot, // Full 3D + colliders (< 100 units)
Warm, // Full 3D, no colliders (< 300 units)
Cool, // Simplified mesh (< 400 units) - NOT IMPLEMENTED
Cold // Billboard (400+ units) - NOT IMPLEMENTED
}

The current system uses a simplified two-tier approach:

  • Hot (< 50 units): Full mesh with colliders enabled
  • Warm (50+ units): Full mesh with colliders disabled

Cool and Cold tiers are defined but BillboardRenderer doesn’t exist yet.


using UnityEngine;
using Cysharp.Threading.Tasks;
using System.Threading;
namespace BugWars.Terrain
{
/// <summary>
/// Renders a 3D object as a camera-facing billboard sprite.
/// WebGL-optimized: Uses GPU instancing, minimal state changes.
/// </summary>
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class BillboardRenderer : MonoBehaviour
{
[Header("Billboard Settings")]
[SerializeField] private bool lockYAxis = true; // Only rotate on Y (trees stay upright)
[SerializeField] private float updateInterval = 0.1f; // Reduce rotation updates
[Header("Runtime State")]
[SerializeField] private bool isBillboardMode = false;
private Transform _cameraTransform;
private MeshRenderer _meshRenderer;
private MeshFilter _meshFilter;
private Mesh _originalMesh;
private Material _originalMaterial;
private Material _billboardMaterial;
private Texture2D _billboardTexture;
private Mesh _quadMesh;
private float _lastUpdateTime;
// Static shared quad mesh for all billboards (GPU instancing)
private static Mesh _sharedQuadMesh;
private static Material _sharedBillboardMaterial;
private void Awake()
{
_meshRenderer = GetComponent<MeshRenderer>();
_meshFilter = GetComponent<MeshFilter>();
// Cache original mesh/material for restoration
_originalMesh = _meshFilter.sharedMesh;
_originalMaterial = _meshRenderer.sharedMaterial;
// Create shared quad if not exists
if (_sharedQuadMesh == null)
{
_sharedQuadMesh = CreateQuadMesh();
}
}
private void Start()
{
// Cache camera transform
var cam = Camera.main;
if (cam != null)
{
_cameraTransform = cam.transform;
}
}
/// <summary>
/// Enable billboard mode - switch from 3D mesh to 2D sprite
/// </summary>
public void EnableBillboard()
{
if (isBillboardMode) return;
isBillboardMode = true;
// Generate billboard texture from current view (or use pre-baked)
if (_billboardTexture == null)
{
_billboardTexture = GetOrCreateBillboardTexture();
}
// Switch to quad mesh
_meshFilter.sharedMesh = _sharedQuadMesh;
// Apply billboard material with texture
if (_billboardMaterial == null)
{
_billboardMaterial = CreateBillboardMaterial(_billboardTexture);
}
_meshRenderer.sharedMaterial = _billboardMaterial;
// Scale quad to match original object bounds
AdjustQuadScale();
}
/// <summary>
/// Disable billboard mode - restore original 3D mesh
/// </summary>
public void DisableBillboard()
{
if (!isBillboardMode) return;
isBillboardMode = false;
// Restore original mesh and material
_meshFilter.sharedMesh = _originalMesh;
_meshRenderer.sharedMaterial = _originalMaterial;
// Reset rotation
transform.rotation = Quaternion.identity;
}
private void LateUpdate()
{
if (!isBillboardMode || _cameraTransform == null) return;
// Throttle rotation updates
if (Time.time - _lastUpdateTime < updateInterval) return;
_lastUpdateTime = Time.time;
// Face camera
if (lockYAxis)
{
// Only rotate on Y axis (trees, poles stay upright)
Vector3 lookDir = _cameraTransform.position - transform.position;
lookDir.y = 0;
if (lookDir != Vector3.zero)
{
transform.rotation = Quaternion.LookRotation(-lookDir);
}
}
else
{
// Full billboard (always face camera)
transform.LookAt(_cameraTransform);
transform.Rotate(0, 180, 0);
}
}
private static Mesh CreateQuadMesh()
{
Mesh mesh = new Mesh();
mesh.name = "BillboardQuad";
mesh.vertices = new Vector3[]
{
new Vector3(-0.5f, 0, 0),
new Vector3(0.5f, 0, 0),
new Vector3(-0.5f, 1, 0),
new Vector3(0.5f, 1, 0)
};
mesh.uv = new Vector2[]
{
new Vector2(0, 0),
new Vector2(1, 0),
new Vector2(0, 1),
new Vector2(1, 1)
};
mesh.triangles = new int[] { 0, 2, 1, 2, 3, 1 };
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
}
}
// Add to EnvironmentObjectLOD.cs
private BillboardRenderer _billboardRenderer;
private void Awake()
{
// ... existing code ...
// Get or add billboard renderer
_billboardRenderer = GetComponent<BillboardRenderer>();
if (_billboardRenderer == null)
{
_billboardRenderer = gameObject.AddComponent<BillboardRenderer>();
}
}
private void SetLODTier(LODTier newTier)
{
if (_currentTier == newTier) return;
_currentTier = newTier;
switch (newTier)
{
case LODTier.Hot:
EnableColliders(true);
_billboardRenderer?.DisableBillboard();
break;
case LODTier.Warm:
EnableColliders(false);
_billboardRenderer?.DisableBillboard();
break;
case LODTier.Cool:
EnableColliders(false);
_billboardRenderer?.DisableBillboard();
// TODO: Switch to simplified mesh if available
break;
case LODTier.Cold:
EnableColliders(false);
_billboardRenderer?.EnableBillboard();
break;
}
}

For best WebGL performance, pre-bake billboards into a texture atlas:

// BillboardAtlas.cs - ScriptableObject for managing billboard textures
[CreateAssetMenu(fileName = "BillboardAtlas", menuName = "BugWars/Billboard Atlas")]
public class BillboardAtlas : ScriptableObject
{
[System.Serializable]
public class BillboardEntry
{
public string assetName;
public Rect uvRect; // UV coordinates in atlas
public Vector2 size; // World size for scaling
}
public Texture2D atlasTexture;
public Material atlasMaterial;
public List<BillboardEntry> entries = new();
public BillboardEntry GetEntry(string assetName)
{
return entries.Find(e => e.assetName == assetName);
}
}
// Editor/BillboardBaker.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
public class BillboardBaker : EditorWindow
{
[MenuItem("BugWars/Bake Billboard Atlas")]
public static void BakeBillboards()
{
// Find all environment prefabs
// Render each from front view
// Pack into atlas texture
// Save atlas and UV mappings
}
}
#endif

All billboards use the same quad mesh and atlas material, enabling GPU instancing:

// In BillboardRenderer
_billboardMaterial.enableInstancing = true;

Single texture atlas = single draw call for all billboards:

  • Pack all billboard sprites into one 2048x2048 or 4096x4096 atlas
  • Use UV coordinates to select correct sprite
  • Dramatically reduces state changes

Billboards don’t need per-frame rotation updates:

[SerializeField] private float updateInterval = 0.1f; // 10 updates/sec is plenty

Further billboards can use lower resolution:

private int GetBillboardResolution(float distance)
{
if (distance > 500f) return 32; // Very distant
if (distance > 400f) return 64; // Distant
return 128; // Near cold tier
}

Async billboard texture loading:

private async UniTask<Texture2D> LoadBillboardTextureAsync(string assetName, CancellationToken ct)
{
// Load from Addressables
var handle = Addressables.LoadAssetAsync<Texture2D>($"Billboards/{assetName}");
return await handle.ToUniTask(cancellationToken: ct);
}

Reactive LOD tier changes:

public ReactiveProperty<LODTier> CurrentTier { get; } = new(LODTier.Hot);
// Subscribe to tier changes
CurrentTier.Subscribe(tier =>
{
if (tier == LODTier.Cold)
{
_billboardRenderer.EnableBillboard();
}
else
{
_billboardRenderer.DisableBillboard();
}
}).AddTo(_disposables);

MetricFull 3D MeshBillboard
Vertices/object500-50004
Draw calls1 per objectBatched (1 for all)
Material switches1 per object0 (shared atlas)
CPU (rotation)N/AMinimal (throttled)
MemoryFull mesh dataShared quad + atlas UV

Expected improvement for 100 distant objects:

  • Draw calls: 100 → 1-5 (with batching)
  • Vertices: 50,000-500,000 → 400
  • Frame time: ~5ms → ~0.5ms

  • Create BillboardRenderer component with runtime texture grab
  • Y-axis lock for upright objects (trees, poles)
  • Throttled camera-facing rotation
  • Integrate with EnvironmentObjectLOD tier system
  • Smooth transition between 3D and billboard modes
  • Proper mesh/material restoration
  • Create BillboardAtlas ScriptableObject
  • Editor tool for baking billboard textures
  • UV-based sprite selection from atlas
  • Shared quad mesh across all billboards
  • Shared atlas material with instancing enabled
  • Batched draw calls
  • Distance-based resolution selection
  • LOD within LOD (multiple billboard quality levels)
  • Memory optimization for mobile/WebGL

  1. Assets/Scripts/Terrain/BillboardRenderer.cs
  2. Assets/Scripts/Terrain/BillboardAtlas.cs (ScriptableObject)
  3. Assets/Editor/BillboardBaker.cs (Editor tool)
  1. Assets/Scripts/Terrain/EnvironmentObjectLOD.cs - Integrate billboard tier

  • Billboards face camera correctly
  • Y-axis lock works for trees/poles
  • Transition from 3D to billboard is smooth
  • GPU instancing reduces draw calls
  • WebGL build maintains 60fps with many billboards
  • Billboard texture quality is acceptable at distance
  • Memory usage is reduced compared to full 3D