Billboard System
Billboard System
Section titled “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.
Architecture Overview
Section titled “Architecture Overview”┌─────────────────────────────────────────────────────────────────┐│ 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 ││ ▼ ││ │└─────────────────────────────────────────────────────────────────┘Current Implementation Status
Section titled “Current Implementation Status”| Component | Status | Description |
|---|---|---|
| Hot/Warm LOD | ✅ Complete | Collider toggling at 50 units |
| BillboardRenderer | 🔲 Planned | Camera-facing sprite renderer |
| BillboardAtlas | 🔲 Planned | Texture atlas for batching |
| BillboardBaker | 🔲 Planned | Editor tool for pre-baking |
| Cool LOD (Simplified Mesh) | 🔲 Planned | Reduced polygon meshes |
| Cold LOD (Billboard) | 🔲 Planned | Full billboard integration |
Current LOD System
Section titled “Current LOD System”Location: Assets/Scripts/Terrain/EnvironmentObjectLOD.cs
Existing Tiers
Section titled “Existing Tiers”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}Current Implementation
Section titled “Current Implementation”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.
Proposed Billboard System
Section titled “Proposed Billboard System”1. BillboardRenderer Component
Section titled “1. BillboardRenderer Component”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; } }}2. Integration with EnvironmentObjectLOD
Section titled “2. Integration with EnvironmentObjectLOD”// 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; }}3. Billboard Atlas System (Recommended)
Section titled “3. Billboard Atlas System (Recommended)”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); }}4. Editor Tool for Billboard Baking
Section titled “4. Editor Tool for Billboard Baking”// Editor/BillboardBaker.cs
#if UNITY_EDITORusing 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 }}#endifWebGL Optimizations
Section titled “WebGL Optimizations”GPU Instancing
Section titled “GPU Instancing”All billboards use the same quad mesh and atlas material, enabling GPU instancing:
// In BillboardRenderer_billboardMaterial.enableInstancing = true;Texture Atlas
Section titled “Texture Atlas”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
Update Throttling
Section titled “Update Throttling”Billboards don’t need per-frame rotation updates:
[SerializeField] private float updateInterval = 0.1f; // 10 updates/sec is plentyDistance-Based Quality
Section titled “Distance-Based Quality”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}UniTask Integration
Section titled “UniTask Integration”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);}R3 Integration
Section titled “R3 Integration”Reactive LOD tier changes:
public ReactiveProperty<LODTier> CurrentTier { get; } = new(LODTier.Hot);
// Subscribe to tier changesCurrentTier.Subscribe(tier =>{ if (tier == LODTier.Cold) { _billboardRenderer.EnableBillboard(); } else { _billboardRenderer.DisableBillboard(); }}).AddTo(_disposables);Performance Comparison
Section titled “Performance Comparison”| Metric | Full 3D Mesh | Billboard |
|---|---|---|
| Vertices/object | 500-5000 | 4 |
| Draw calls | 1 per object | Batched (1 for all) |
| Material switches | 1 per object | 0 (shared atlas) |
| CPU (rotation) | N/A | Minimal (throttled) |
| Memory | Full mesh data | Shared 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
Implementation Phases
Section titled “Implementation Phases”Phase 1: Basic BillboardRenderer
Section titled “Phase 1: Basic BillboardRenderer”- Create
BillboardRenderercomponent with runtime texture grab - Y-axis lock for upright objects (trees, poles)
- Throttled camera-facing rotation
Phase 2: LOD Integration
Section titled “Phase 2: LOD Integration”- Integrate with
EnvironmentObjectLODtier system - Smooth transition between 3D and billboard modes
- Proper mesh/material restoration
Phase 3: Billboard Atlas System
Section titled “Phase 3: Billboard Atlas System”- Create
BillboardAtlasScriptableObject - Editor tool for baking billboard textures
- UV-based sprite selection from atlas
Phase 4: GPU Instancing
Section titled “Phase 4: GPU Instancing”- Shared quad mesh across all billboards
- Shared atlas material with instancing enabled
- Batched draw calls
Phase 5: Quality Scaling
Section titled “Phase 5: Quality Scaling”- Distance-based resolution selection
- LOD within LOD (multiple billboard quality levels)
- Memory optimization for mobile/WebGL
Files to Create/Modify
Section titled “Files to Create/Modify”New Files
Section titled “New Files”Assets/Scripts/Terrain/BillboardRenderer.csAssets/Scripts/Terrain/BillboardAtlas.cs(ScriptableObject)Assets/Editor/BillboardBaker.cs(Editor tool)
Modified Files
Section titled “Modified Files”Assets/Scripts/Terrain/EnvironmentObjectLOD.cs- Integrate billboard tier
Testing Checklist
Section titled “Testing Checklist”- 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