// Serwus Studio · Unity Asset Store · v2.0

Crowd Cheer System - Documentation

A lightweight, GPU-driven crowd system for Unity URP. Thousands of animated spectators in a stadium, arena or concert hall - no rigged meshes, no Animators, no per-frame CPU cost. This page is the full online reference: Quick Start, atlas, components, scripting API, shader parameters, performance and FAQ.

Unity2020.3 LTS+ PipelineURP 10+ ShaderSerwusStudio/CrowdCheer NamespaceSerwusStudio PlatformsAndroid · iOS · Quest 1/2/3 · PC · WebGL

01Quick Start

Two paths: the shader alone (30 seconds, zero scripts) or the full system with global control and gameplay triggers. Start with the first, move to the second when you want the wave and the bounce.

A. Shader only - the minimum

  1. Drop a Quad into your scene, or the included plate_crowd.fbx model.
  2. Create a Material and set the shader to SerwusStudio/CrowdCheer.
  3. Assign your atlas to the Crowd Texture Atlas slot.
  4. Set Columns in Atlas = number of character variants (horizontal) and Anim Rows in Atlas = number of animation frames (vertical).
  5. Dial in Tiling X / Y - that's how many characters fit on the plane.

Done. Characters animate on their own, with a randomized variant and a randomized phase offset. No scripts involved.

B. Full system - recommended

  1. Place plate_crowd prefabs wherever the audience belongs (stands, sections, seating tiers).
  2. Add CrowdController to each plane and set the per-plane values: atlas, columns, rows, tiling, offset.
  3. Create an empty GameObject and add CrowdManager. Hit Auto Collect All - it gathers every controller in the scene.
  4. Drive the whole crowd from the Manager: density, animation speed, fade, color, randomization, quality.
  5. Optionally add CrowdWaveTrigger - wave and jump from a key, a UI button or a collider. No code required.
Requirements

Unity 2020.3 LTS or newer, Universal Render Pipeline (URP 10+). The shader is hand-written HLSL - Shader Graph is not needed. Every component is [ExecuteAlways], so the crowd is live in the editor without entering Play mode.

02How it works

The crowd renders entirely on the GPU. No GameObject per character, no Animators, no skinned meshes. A single flat plane displays dozens of animated characters through texture atlas tiling - one draw call regardless of how many characters are visible.

The fragment path

  1. Clip bounds - when enabled, fragments outside the world-space box are discarded immediately.
  2. Distance fade - camera distance is evaluated first; past Fade End the fragment is rejected before anything else runs. That's a fillrate saving, not just a visual effect.
  3. Tiling - UVs multiplied by Tiling X/Y; the integer part is the tileID (one tile = one character), the fractional part is the local UV inside the tile.
  4. Plane seed - the randomization seed is derived from the object pivot (unity_ObjectToWorld). Constant across the mesh, different for every plane in the scene - two identical plates side by side show a different crowd.
  5. Person ID - planeSeed + tileID + RandomSeed. That single hash decides: whether the tile is occupied (density), which character variant, whether it is mirrored, its animation phase offset, and its tint shift.
  6. Animation - frame = floor(_Time.y * AnimSpeed + timeOffset), modulo the row count. Each character carries its own timeOffset, so nobody claps in lockstep with their neighbour.
  7. Wave / La Ola - the world-space position is projected onto the wave direction; if the fragment falls inside the Wave Width band, the animation row is swapped for Wave Pose. At Wave Speed = 0 the whole block is skipped - zero cost.
  8. Sampling and masking - padded atlas UVs, a soft edge mask (Edge Smoothness), alpha cutout.
Determinism

All randomization is deterministic - the same hash produces the same result every frame and on every machine. The crowd never flickers, empty seats never jump around, and the scene looks identical in the editor and in a build. To reshuffle the layout, change Random Seed.

Passes

The shader has two passes: ForwardLit (color, main directional light + ambient SH) and DepthOnly. Both share the same CrowdTile() function, so depth always matches what you see. AlphaTest queue with ZWrite On - no transparency sorting.

03Texture atlas

The atlas is a uniform grid. Columns are character variants (shirt color, body type), rows are animation frames played top to bottom in sequence.

A 4 × 4 atlas → 4 character variants × 4 animation frames. With Random Flip X on it reads as 8 variants, at no extra memory cost.

Requirements

  • All cells the same size, a grid with no gaps.
  • Fully transparent background (alpha = 0) - the cutout depends on it.
  • A few pixels of transparent padding around each character - without it, texture filtering bleeds pixels from the neighbouring cell.
  • PNG or TGA with an alpha channel, power-of-two resolution (512², 1024², 2048²).
  • On mobile / VR: Crunch compression in the import settings; 512² or 1024² is plenty.
  • Set Columns in Atlas and Anim Rows in Atlas in the material to match the grid exactly. A mismatch means sliced characters.
Tip

Unity's Tiling/Offset fields are hidden for this texture ([NoScaleOffset]) - deliberately. Use the UV Tiling section in the material or the fields on CrowdController; they give finer control and work with the per-plane MaterialPropertyBlock.

04Components

Three runtime components and two custom inspectors. The key rule: CrowdManager is optional. CrowdController works standalone - the Manager simply takes over its global settings.

CrowdController

The per-plane component. [ExecuteAlways], [DisallowMultipleComponent], requires a Renderer on the same GameObject. Every value reaches the shader through a MaterialPropertyBlock - the shared material is never modified, so no material instances are created and GPU instancing is preserved.

  • Per-plane: atlas, column and row counts, Tiling X/Y, Offset X/Y, clip bounds.
  • Global: density, animation speed, fade, color, randomization, quality, wave parameters - driven by the Manager, editable locally when there isn't one.
  • No Update, no LateUpdate. Values are pushed to the GPU only when something changes. Zero per-frame CPU cost.

CrowdManager

The scene-level component, usually one per scene. A single slider changes density on every plane at once. It keeps a list of controllers (Auto Collect gathers them on Awake) and, on every change, issues one batched SetGlobalParams() per controller - not one call per property.

  • Global: Density, Anim Speed, Fade Start/End, Tint Color, Tint Variation, Random Seed, Flip, Edge Smoothness, Alpha Clip.
  • Wave: direction, speed, band width, cheer-pose row, duration (0 = infinite).
  • Jump: height, duration, wave propagation and delay per world unit.

CrowdWaveTrigger

Fires waves and jumps with no code. Needs a reference to the CrowdManager. Three modes, freely combined:

  1. Keyboard - Trigger On Key Press plus a key (Space by default).
  2. UI Button - in onClick, drag the object in and pick TriggerWave() or TriggerJump().
  3. Collider zone - a Collider with Is Trigger, plus an optional tag filter (Player by default).

On top of that: a Cooldown (minimum gap between waves - blocks spamming) and two UnityEvents, On Wave Triggered and On Wave Blocked. Hook up a crowd roar, a controller rumble, whatever you need.

Custom inspectors

Both components ship with foldout inspectors. CrowdControllerEditor detects whether a Manager exists in the scene: global sections turn read-only (so you never edit the same value in two places) while per-plane sections stay active. It warns about a missing Renderer, a missing material and the wrong shader. CrowdManagerEditor gives you a controller count, Auto Collect All / Apply Globals Now buttons, and Trigger Wave / Jump plus Stop in Play mode. It also validates that Fade Start is below Fade End.

05Scripting API

Namespace SerwusStudio. Everything you set in the Inspector has a code equivalent - setters push to the GPU themselves, there is nothing to „refresh”.

CrowdManager - properties

MemberTypeDescription
DensityLevelfloat 0–1Crowd density across every plane.
AnimSpeedfloat 0.1–10Animation speed in frames per second.
FadeStartfloat 0–200Distance where the fade begins.
FadeEndfloat 0–500Distance of full fade-out (beyond it, fragments are discarded).
TintColorColorGlobal base color of the crowd.
TintVariationfloat 0–1Per-character warm/cool variation.
RandomSeedfloat 0–100Randomization seed - changing it reshuffles the whole crowd.
FlipEnabledboolRandom horizontal mirror on ~50% of characters.
Smoothnessfloat 0–0.3Softness of the tile edges.
AlphaClipfloat 0–1Alpha cutout threshold.
JumpHeightfloat 0.05–5Jump height in world units.
JumpDurationfloat 0.1–3Jump duration for a single plane.
ControllersIReadOnlyListList of registered controllers (read-only).
IsWaveActiveboolWhether a wave is currently running (read-only).

CrowdManager - methods

MemberDescription
AutoCollect()Finds every CrowdController in the scene and pushes the global settings to them.
RegisterController(c)Adds a controller to the list and immediately gives it the globals - for planes spawned at runtime.
UnregisterController(c)Removes a controller from the list (call it before destroying the object).
ApplyGlobals()Pushes all global settings to every controller.
TriggerWave()A wave using the Inspector settings. Safe for a UI Button onClick.
TriggerWave(angle, speed, width, waveRow, duration)A wave with custom parameters. duration = 0 → runs indefinitely.
TriggerWaveFrom(worldOrigin)A wave radiating OUT from a world-space point - e.g. away from the goal after a score.
TriggerWaveToward(worldTarget)A wave converging TOWARD a point - e.g. onto the stage.
StopWave()Stops the wave on every plane immediately.
TriggerJump()Bounces every plane. With wave propagation on, each one jumps with a delay based on its position. Play mode only.
TriggerJumpFrom(worldOrigin)A radial bounce from a point - closer planes jump first. Play mode only.
StopJump()Stops all jumps and restores positions.

CrowdController - methods

MemberDescription
Apply()Pushes all values into the MaterialPropertyBlock. Called automatically on every change.
SetGlobalParams(…)Sets 10 global parameters at once with a single Apply(). Used by the Manager.
SetWaveParams(angle, speed, width, row)Sets the wave parameters at once with a single Apply().
TriggerWave(duration = 5f)A one-shot wave on this plane. If Wave Speed is 0 it defaults to 2. Play mode only.
StopWave()Stops the wave and resets Wave Speed to 0.
TriggerJump(height, duration, delay = 0f)A bounce along a sine curve: y = sin(t · π) · height. The delay is what makes propagation work. Play mode only.
StopJump()Stops the bounce and restores the original position.
SetClipBounds(min, max)Enables and sets the world-space clipping box.
SetClipBoundsFromRenderer()Sets the bounds from the Renderer's current world bounds.
ClearClipBounds()Disables clipping.

CrowdWaveTrigger - methods

MemberDescription
TriggerWave()A wave, respecting the cooldown. With Also Trigger Jump enabled it fires the bounce too.
TriggerJump()The bounce alone, no wave. Cooldown applies as well.
TriggerWaveFromThisPosition()A wave radiating out from this object's position.
StopWave()Stops the wave on the Manager.

Examples

using SerwusStudio;

public class MatchEvents : MonoBehaviour
{
    public CrowdManager crowd;
    public Transform homeGoal;

    // goal - the crowd goes wild: faster animation, wave from the goal, jump
    public void OnGoalScored()
    {
        crowd.AnimSpeed = 8f;
        crowd.TriggerWaveFrom(homeGoal.position);
        crowd.TriggerJump();
        Invoke(nameof(CalmDown), 6f);
    }

    void CalmDown() => crowd.AnimSpeed = 2f;

    // team sectors - two Managers or two sets of materials
    public void SetHomeColors()
    {
        crowd.TintColor     = new Color(1f, 0.82f, 0.82f);
        crowd.TintVariation = 0.25f;
    }

    // stadion pustoszeje po meczu
    IEnumerator EmptyStadium(float seconds)
    {
        float t = 0f;
        while (t < seconds)
        {
            t += Time.deltaTime;
            crowd.DensityLevel = Mathf.Lerp(1f, 0f, t / seconds);
            yield return null;
        }
    }

    // quality profile for VR / Quest
    public void ApplyVRProfile()
    {
        crowd.FadeStart = 30f;
        crowd.FadeEnd   = 60f;
    }
}

Register runtime-spawned planes manually - they then receive the full set of global settings:

var plane = Instantiate(crowdPrefab, position, rotation);
crowd.RegisterController(plane.GetComponent<CrowdController>());

// przy usuwaniu
crowd.UnregisterController(controller);
Destroy(controller.gameObject);
No scripts

If you would rather drive the material directly, every property is reachable through Material.SetFloat / SetColor - names are in the table in section 06. Just remember that modifying a shared material creates an instance of it; the components avoid that by using a MaterialPropertyBlock.

06Shader parameters

The complete property list for the SerwusStudio/CrowdCheer shader - Inspector label, code-facing property name, range and default.

PropertyInspector nameRange / defaultDescription
_MainTexCrowd Texture AtlasTexture2DThe sprite atlas: columns = variants, rows = frames. Unity's Tiling/Offset is hidden - use the UV Tiling section.
_AnimSpeedAnimation Speed (FPS)0.1–10 · 2Frame cycling rate. 1–2 idle crowd, 3–5 cheering, 6–10 fast clapping.
_AnimRowCountAnim Rows in Atlas1–8 int · 4Number of animation frame rows in the atlas. Must match the texture.
_ColumnsCountColumns in Atlas1–16 int · 4Number of character variant columns. Must match the texture.
_TilingXTiling X0.1–50 · 1How many characters fit across the plane width.
_TilingYTiling Y0.1–50 · 1How many rows of characters fit across the plane height.
_OffsetXOffset X-1–1 · 0Horizontal UV shift - fine-tuning against the geometry.
_OffsetYOffset Y-1–1 · 0Vertical shift - aligning the „ground line” with the edge of the seating.
_RandomSeedRandom Seed0–100 · 0Reshuffles variants globally without moving any geometry.
_FlipEnabledRandom Flip Xtoggle · onMirrors ~50% of characters. Doubles variety for free. Turn it off for numbered jerseys.
_DensityLevelDensity Level0–1 · 1Percentage of occupied seats. 1 = sold out, 0.3 = sparse, 0 = empty. Deterministic - it never flickers.
_FadeStartFade Start0–200 · 50Distance at which the crowd starts to fade.
_FadeEndFade End0–500 · 100Distance of full fade-out. Beyond it fragments are discarded - a real fillrate saving.
_TintColorBase TintColor · whiteGlobal color multiplier. Club colors, cool night light, a sunset wash.
_TintVariationTint Variation0–1 · 0Warm/cool shift per character. Even 0.1–0.2 visibly lifts a crowd built from few columns.
_WaveSpeedWave Speed0–10 · 0Wave speed. 0 = disabled (the shader skips the block). 1–2 stately, 3–5 natural, 6–10 fast.
_WaveRowWave Pose0–7 int · 2The atlas row holding the cheer pose (arms up). Zero-indexed.
_WaveWidthWave Width0.5–30 · 5Wave band width in world units - how many people cheer at once.
_WaveAngleWave Direction0–360 · 0Propagation direction in degrees. 0° = +X, 90° = +Z, 180° = −X, 270° = −Z.
_SmoothnessEdge Smoothness0–0.3 · 0.05Soft fade at the tile edges. Seeing black lines between characters? Raise it to 0.05–0.1.
_AlphaClipAlpha Clip Threshold0–1 · 0.3Alpha cutoff. Hard-edged atlas: 0.3–0.5. Soft, anti-aliased: 0.1–0.2.
_ClipBoundsEnabledEnable Clip Boundstoggle · offClipping against a world-space box. Off by default - zero cost when unused.
_ClipBoundsMin / MaxClip Bounds Min / Max XYZVector3Corners of the clipping box in world space.

07Clip Bounds

Flat crowd planes on curved stands like to intersect - a corner of one plate pokes out from behind another, or through a railing. Clip Bounds solves it without cutting geometry: fragments outside a given world-space box are simply discarded.

  • Enable Enable Clip Bounds on the CrowdController and set the Min/Max corners.
  • The selected object draws an orange gizmo of the box - you see exactly what gets clipped.
  • SetClipBoundsFromRenderer() snaps the box to the Renderer's current bounds - a good starting point before narrowing it by hand.
  • The test is the first thing the shader does, so clipped fragments cost nothing further.
// clip the crowd to the sector bounds
controller.SetClipBounds(sector.bounds.min, sector.bounds.max);

// start from the mesh bounds, then narrow it down by hand
controller.SetClipBoundsFromRenderer();

// disable
controller.ClearClipBounds();

08Performance & VR

The system is lightweight by design, but the bottleneck will always be fillrate - the number of screen pixels covered by crowd planes. Everything below comes down to reducing that number.

What's free by construction

  • Zero per-frame CPU cost - no component has an Update or LateUpdate. Data reaches the GPU only on change.
  • MaterialPropertyBlock - no material duplication, GPU instancing preserved.
  • Batched Apply() - a global change is one call per controller, not one per property.
  • The wave costs nothing while asleep - at Wave Speed = 0 the shader never enters the wave block.
  • Alpha cutout, not transparency - AlphaTest queue with ZWrite On, no transparency sorting.

VR / Quest / mobile

  • Be aggressive with Distance Fade. On Quest: Fade Start 30, Fade End 60. On mobile: 40 / 80. Distant stands add nothing and cost fillrate.
  • Lower tiling on far plates. A plane 40 metres out doesn't need 10×10 characters - 3×3 looks the same.
  • 512² or 1024² atlas with Crunch compression. Characters are seen from a distance; the difference doesn't show.
  • Enable GPU Instancing on the material when many planes share it.
  • Single Pass Instanced works. The shader carries full stereo support: UNITY_VERTEX_INPUT_INSTANCE_ID, UNITY_VERTEX_OUTPUT_STEREO, UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX. Tested on Quest 2 and Quest 3.

PC and console

You can afford a 2048² atlas, Fade End at 200+ and dense tiling. Lighting is the main directional light plus ambient SH - a realistic response to the scene at minimal cost.

Budget

Tuning order when the framerate drops: Fade End first, then tiling on distant plates, and only then atlas resolution. The first two win the most, because they directly reduce the number of rendered pixels.

09FAQ

There are black lines between characters.
Raise Edge Smoothness to 0.05–0.1. Also check that your atlas has transparent padding around each cell - without it, texture filtering pulls pixels from the neighbouring cell.
Every character looks the same.
Columns in Atlas has to match the actual column count in the texture. Also confirm Random Flip X is on, and push Tint Variation to 0.2.
The wave never shows up.
Set Wave Speed above 0 (say 3) and check that Wave Pose points at a row that exists in your atlas (zero-indexed). From the Manager: TriggerWave() starts a coroutine, so it only runs in Play mode.
Characters are misaligned or floating.
Adjust Offset Y to sit the ground line on the edge of the seating. Also verify the mesh UVs run from 0 to 1.
The sliders on CrowdController are greyed out.
That's intentional - there's a CrowdManager in the scene and it owns the global settings. Edit them on the Manager; the per-plane sections (atlas, tiling) stay active. Remove the Manager and the Controller goes back to standalone mode.
Two neighbouring planes show an identical crowd layout.
The seed comes from the object pivot - if the planes share a pivot position (children of the same object with no offset, for instance) they get the same layout. Move the pivots apart, or change Random Seed on one of them.
Does it work with Shader Graph / HDRP / Built-in?
The shader is hand-written HLSL for URP - for performance and full control. Shader Graph isn't needed; everything is set in the material inspector. HDRP and Built-in are not supported.
Can I use my own atlas?
Yes, that's the primary use case. Build a grid where columns are character variants and rows are animation frames, then set the column/row counts in the material. Requirements are in section 03.
How do I make separate home and away sections?
Separate material instances with a different Base Tint and Random Seed. If you're driving it from code, use two CrowdManagers, each with its own controller list.
How many characters can it render?
Tested with thousands of visible characters on Quest 2 at a stable framerate. The limit isn't the character count but fillrate - how much of the screen the planes cover. Distance Fade is the dial for that.

10Changelog

2.0.02026-06-27

Major update - Crowd Cheer is now a full scripting system, not just a shader. New: CrowdManager / CrowdController / CrowdWaveTrigger, runtime API (TriggerWave, TriggerJump…), jump/bounce effect with wave-style propagation, world-space clip bounds, two demo scenes (Concert, Stadium), custom inspectors with preview, expanded EN + PL documentation. Changed: randomization seeding derived from the mesh pivot (more stable). Removed: the „Randomize Scale” parameter.

1.0.02026-03

Initial release. GPU atlas-based crowd shader for URP (no rigged meshes), material-driven controls (density, speed, UV tiling, randomization, distance fade, tint, edge smoothness, alpha clip), built-in La Ola wave parameters, demo scene, prefabs, sample materials and textures, EN + PL documentation.