Skip to main content

Surface Renderer

The SurfaceLodRenderer component handles ocean mesh rendering using a quadtree-based Level of Detail (LOD) system. It generates visible patches each frame, performs frustum culling, and renders using GPU instancing.

Overview

The renderer implements several optimization techniques:

  • Quadtree LOD - Subdivides the ocean surface based on camera distance
  • Frustum Culling - Only renders patches visible to the camera
  • Instanced Rendering - Batches patches to minimize draw calls
  • Seam Stitching - Uses 16 mesh variants to prevent T-junction artifacts
  • Horizon Skirting - Extends the ocean to the horizon without extra geometry

Quadtree LOD Visualization Quadtree subdivision showing higher detail near camera (wireframe view)

Adding to Scene

  1. Create a GameObject as a child of your ocean system root
  2. Name it SurfaceRenderer
  3. Add the SurfaceLodRenderer component
  4. Assign the ocean material
using UnityEngine;
using PlatypusIdeas.VROcean.Runtime.Scripts.Ocean;

GameObject renderGo = new GameObject("SurfaceRenderer");
SurfaceLodRenderer renderer = renderGo.AddComponent<SurfaceLodRenderer>();
renderer.SurfaceMaterial = oceanMaterial;
renderer.OceanSize = 1024f;
renderer.Quality = SurfaceLodRenderer.QualityLevel.Medium;

Inspector Fields

SurfaceLodRenderer Inspector SurfaceLodRenderer component with all settings

Required References

FieldTypeDescription
Surface MaterialMaterialOcean shader material for rendering

Ocean Size

FieldRangeDefaultDescription
Ocean Size256 - 81921024Total ocean extent in world units

The ocean is centered on the camera position and extends OceanSize / 2 in each direction.

Quality

FieldTypeDescription
QualityQualityLevelPreset controlling mesh density and LOD behavior

Advanced

FieldRangeDefaultDescription
LOD Level Override0 - 120Manual LOD depth (0 = automatic)
Max Height Override0+0Manual culling bounds height (0 = automatic)
Skirting Multiplier1 - 2010Horizon extension scale

Debug (Editor Only)

FieldTypeDescription
Freeze QuadtreeboolStop LOD updates for debugging

Quality Presets

The Quality setting controls mesh density and LOD transition distances.

PresetVertices Per PatchLOD ThresholdCull ScaleBest For
Low162.01.2Quest standalone, low-end
Medium321.51.5Quest, balanced
High641.21.8PC VR
Ultra641.02.0High-end PC

Vertices Per Patch: Grid resolution of each rendered patch. Higher values create smoother surfaces but more triangles.

LOD Threshold: Distance multiplier for LOD transitions. Lower values increase detail at distance but render more patches.

Cull Scale: Multiplier for frustum culling bounds. Higher values reduce popping at screen edges but may render off-screen patches.

// Set quality based on platform
#if UNITY_ANDROID
renderer.Quality = SurfaceLodRenderer.QualityLevel.Low;
#elif UNITY_STANDALONE
renderer.Quality = SurfaceLodRenderer.QualityLevel.High;
#endif

Public Properties

SurfaceMaterial

public Material SurfaceMaterial { get; set; }

The material used for ocean rendering. Changing this invalidates the cache and triggers a rebuild.

// Switch materials at runtime
renderer.SurfaceMaterial = stormyOceanMaterial;

OceanSize

public float OceanSize { get; set; }

Total ocean extent in world units. Clamped to 256-8192 range.

// Expand ocean for open-world scenarios
renderer.OceanSize = 4096f;

Quality

public QualityLevel Quality { get; set; }

Current quality preset. Changes trigger mesh rebuild.

// Dynamic quality adjustment
if (frameRate < 72f)
{
renderer.Quality = SurfaceLodRenderer.QualityLevel.Low;
}

Version

public int Version { get; set; }

Internal version counter. Increment to force mesh rebuild.

// Force rebuild after parameter changes
renderer.Version++;

Public Methods

RenderForCameras

public void RenderForCameras(List<Camera> cameras, MaterialPropertyBlock propertyBlock)

Main rendering entry point. Called by SceneSystem during the render pipeline callback.

// Manual rendering (if not using SceneSystem)
List<Camera> cameras = new List<Camera> { Camera.main };
MaterialPropertyBlock props = new MaterialPropertyBlock();

// Set required properties
props.SetFloat("_OceanRcpScale", 1f / wavePatternSize);
props.SetFloat("_OceanChoppiness", choppiness);

renderer.RenderForCameras(cameras, props);

The method:

  1. Rebuilds meshes if version changed
  2. Updates quadtree for each camera
  3. Submits visible patches via Graphics.DrawMeshInstanced
  4. Renders horizon skirting geometry

How Quadtree LOD Works

Traversal

Each frame, the renderer traverses a quadtree starting from a single root node covering the entire ocean:

  1. Calculate node center and size
  2. Measure distance from camera to node
  3. If close enough, subdivide into 4 children
  4. If far enough or at max depth, render the node
  5. Record LOD level in subdivision map for seam stitching
Level 0:  [          Root          ]
↓ subdivide
Level 1: [ NW ][ NE ][ SW ][ SE ]
↓ subdivide near camera
Level 2: [NW][NE][SW][SE] ...

LOD Selection

The subdivision threshold is calculated as:

threshold = nodeSize * LODThreshold
shouldSubdivide = distanceToCamera < threshold

Lower LODThreshold values cause subdivision at greater distances, increasing detail.

Seam Stitching

When adjacent patches have different LOD levels, their edges have different vertex counts. This creates T-junction artifacts (gaps or cracks).

The renderer solves this with 16 pre-computed mesh variants, one for each combination of edge conditions:

FlagDirectionMeaning
East+XNeighbor has coarser LOD
North+ZNeighbor has coarser LOD
West-XNeighbor has coarser LOD
South-ZNeighbor has coarser LOD
// 16 combinations: None, East, North, North|East, West, ...
[Flags]
public enum AdjacentLodFlags
{
None = 0,
East = 1,
North = 2,
West = 4,
South = 8
}

Each variant has adjusted edge triangulation that matches the coarser neighbor.

Horizon Skirting

Beyond the detailed LOD region, the renderer draws simple quad strips extending to the horizon. This creates the illusion of infinite ocean without the cost of rendering distant patches.

Skirting geometry:

  • Extends from OceanSize / 2 to OceanSize * SkirtingMultiplier / 2
  • Rendered in 4 directions (N, E, S, W)
  • Uses the same material but no displacement sampling
// Adjust horizon distance
renderer.SkirtingMultiplier = 15f; // Extends to 15x ocean size

Instanced Rendering

Visible patches are batched by mesh variant and rendered with Graphics.DrawMeshInstanced:

// Internal batching (simplified)
Matrix4x4[][] instanceMatrices = new Matrix4x4[16][]; // 16 submeshes
int[] instanceCounts = new int[16];

// For each visible patch
int submeshIndex = (int)adjacentFlags;
instanceMatrices[submeshIndex][count] = patchMatrix;
instanceCounts[submeshIndex]++;

// Render each batch
for (int i = 0; i < 16; i++)
{
if (instanceCounts[i] > 0)
{
Graphics.DrawMeshInstanced(
patchMesh,
i, // submesh index
material,
instanceMatrices[i],
instanceCounts[i],
propertyBlock
);
}
}

Maximum instances per batch: 128. Exceeding this logs a warning.

Frustum Culling

Each patch is tested against the camera frustum before rendering:

// Culling test (simplified)
bool IsNodeVisible(Vector3 center, float size)
{
Vector3 extents = new Vector3(
size * cullScale * 0.5f,
maxHeight * 0.5f,
size * cullScale * 0.5f
);

// Test against 6 frustum planes
for (int i = 0; i < 6; i++)
{
Vector4 plane = frustumPlanes[i];
Vector3 testPoint = GetFarthestPointInDirection(center, extents, plane);

if (Vector3.Dot(testPoint, plane) + plane.w < 0)
return false; // Outside frustum
}
return true;
}

The Cull Scale quality parameter expands culling bounds to prevent popping at screen edges.

Performance

Draw Call Efficiency

ScenarioApproximate Draw Calls
Looking at horizon8-16
Looking down at water16-32
Complex scene with reflections32-64

Instancing keeps draw calls low regardless of visible patch count.

Vertex Count by Quality

QualityVertices/PatchTypical PatchesTotal Vertices
Low28920-405,780 - 11,560
Medium1,08920-4021,780 - 43,560
High4,22520-4084,500 - 169,000
Ultra4,22530-60126,750 - 253,500

Optimization Tips

Reduce ocean size when possible:

// Smaller ocean = fewer LOD levels = faster traversal
renderer.OceanSize = 512f; // Instead of default 1024

Use appropriate quality:

// Match quality to viewing conditions
if (cameraIsUnderwater)
{
renderer.Quality = QualityLevel.Low; // Less visible detail needed
}

Limit LOD levels:

// Manual LOD cap for consistent performance
renderer.LodLevelOverride = 5; // Max 5 levels regardless of ocean size

Camera Types

The renderer processes specific camera types:

Camera TypeRendered
GameYes
SceneViewYes
ReflectionYes
PreviewNo
VRNo (handled as Game)
// Internal camera filter
if (cam.cameraType is not CameraType.Game
and not CameraType.SceneView
and not CameraType.Reflection)
{
continue; // Skip this camera
}

Debugging

Freeze Quadtree

Enable Freeze Quadtree in the inspector to stop LOD updates. Useful for:

  • Inspecting current subdivision
  • Testing seam stitching
  • Performance profiling without traversal overhead

Visualizing LOD

The subdivision map stores LOD levels per cell. Access for debugging:

// Note: _subdivisionMap is private, but you can visualize via shader
// Pass LOD data to shader for color-coded rendering

Profiler Markers

The renderer includes profiler markers:

  • LOD Traversal - Quadtree traversal time
  • Patch Submission - Matrix building time

View in Unity Profiler under "Scripts".

Troubleshooting

Ocean Not Visible

  • Verify Surface Material is assigned
  • Check camera is within OceanSize range
  • Ensure RenderForCameras is being called (via SceneSystem or manually)

Gaps Between Patches

  • Seam stitching mesh variants may not be generated
  • Force rebuild by incrementing Version
  • Check that all 16 submeshes exist in the patch mesh

Low Frame Rate

  • Reduce Quality preset
  • Decrease OceanSize
  • Increase LOD threshold (reduces detail at distance)

Patches Popping In/Out

  • Increase Cull Scale in quality preset
  • Check for camera near plane issues
  • Verify Max Height Override covers actual wave height

Horizon Looks Wrong

  • Adjust Skirting Multiplier (increase for further horizon)
  • Ensure skybox blends with ocean at horizon
  • Check fog settings

Next Steps