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
- Drop a Quad into your scene, or the included
plate_crowd.fbxmodel. - Create a Material and set the shader to
SerwusStudio/CrowdCheer. - Assign your atlas to the Crowd Texture Atlas slot.
- Set Columns in Atlas = number of character variants (horizontal) and Anim Rows in Atlas = number of animation frames (vertical).
- 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
- Place
plate_crowdprefabs wherever the audience belongs (stands, sections, seating tiers). - Add
CrowdControllerto each plane and set the per-plane values: atlas, columns, rows, tiling, offset. - Create an empty GameObject and add
CrowdManager. Hit Auto Collect All - it gathers every controller in the scene. - Drive the whole crowd from the Manager: density, animation speed, fade, color, randomization, quality.
- Optionally add
CrowdWaveTrigger- wave and jump from a key, a UI button or a collider. No code required.
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
- Clip bounds - when enabled, fragments outside the world-space box are discarded immediately.
- 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.
- 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. - 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. - 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. - Animation -
frame = floor(_Time.y * AnimSpeed + timeOffset), modulo the row count. Each character carries its owntimeOffset, so nobody claps in lockstep with their neighbour. - 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.
- Sampling and masking - padded atlas UVs, a soft edge mask (Edge Smoothness), alpha cutout.
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.
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:
- Keyboard - Trigger On Key Press plus a key (Space by default).
- UI Button - in
onClick, drag the object in and pickTriggerWave()orTriggerJump(). - Collider zone - a Collider with Is Trigger, plus an optional tag filter (
Playerby 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
| Member | Type | Description |
|---|---|---|
| DensityLevel | float 0–1 | Crowd density across every plane. |
| AnimSpeed | float 0.1–10 | Animation speed in frames per second. |
| FadeStart | float 0–200 | Distance where the fade begins. |
| FadeEnd | float 0–500 | Distance of full fade-out (beyond it, fragments are discarded). |
| TintColor | Color | Global base color of the crowd. |
| TintVariation | float 0–1 | Per-character warm/cool variation. |
| RandomSeed | float 0–100 | Randomization seed - changing it reshuffles the whole crowd. |
| FlipEnabled | bool | Random horizontal mirror on ~50% of characters. |
| Smoothness | float 0–0.3 | Softness of the tile edges. |
| AlphaClip | float 0–1 | Alpha cutout threshold. |
| JumpHeight | float 0.05–5 | Jump height in world units. |
| JumpDuration | float 0.1–3 | Jump duration for a single plane. |
| Controllers | IReadOnlyList | List of registered controllers (read-only). |
| IsWaveActive | bool | Whether a wave is currently running (read-only). |
CrowdManager - methods
| Member | Description |
|---|---|
| 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
| Member | Description |
|---|---|
| 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
| Member | Description |
|---|---|
| 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);
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.
| Property | Inspector name | Range / default | Description |
|---|---|---|---|
| _MainTex | Crowd Texture Atlas | Texture2D | The sprite atlas: columns = variants, rows = frames. Unity's Tiling/Offset is hidden - use the UV Tiling section. |
| _AnimSpeed | Animation Speed (FPS) | 0.1–10 · 2 | Frame cycling rate. 1–2 idle crowd, 3–5 cheering, 6–10 fast clapping. |
| _AnimRowCount | Anim Rows in Atlas | 1–8 int · 4 | Number of animation frame rows in the atlas. Must match the texture. |
| _ColumnsCount | Columns in Atlas | 1–16 int · 4 | Number of character variant columns. Must match the texture. |
| _TilingX | Tiling X | 0.1–50 · 1 | How many characters fit across the plane width. |
| _TilingY | Tiling Y | 0.1–50 · 1 | How many rows of characters fit across the plane height. |
| _OffsetX | Offset X | -1–1 · 0 | Horizontal UV shift - fine-tuning against the geometry. |
| _OffsetY | Offset Y | -1–1 · 0 | Vertical shift - aligning the „ground line” with the edge of the seating. |
| _RandomSeed | Random Seed | 0–100 · 0 | Reshuffles variants globally without moving any geometry. |
| _FlipEnabled | Random Flip X | toggle · on | Mirrors ~50% of characters. Doubles variety for free. Turn it off for numbered jerseys. |
| _DensityLevel | Density Level | 0–1 · 1 | Percentage of occupied seats. 1 = sold out, 0.3 = sparse, 0 = empty. Deterministic - it never flickers. |
| _FadeStart | Fade Start | 0–200 · 50 | Distance at which the crowd starts to fade. |
| _FadeEnd | Fade End | 0–500 · 100 | Distance of full fade-out. Beyond it fragments are discarded - a real fillrate saving. |
| _TintColor | Base Tint | Color · white | Global color multiplier. Club colors, cool night light, a sunset wash. |
| _TintVariation | Tint Variation | 0–1 · 0 | Warm/cool shift per character. Even 0.1–0.2 visibly lifts a crowd built from few columns. |
| _WaveSpeed | Wave Speed | 0–10 · 0 | Wave speed. 0 = disabled (the shader skips the block). 1–2 stately, 3–5 natural, 6–10 fast. |
| _WaveRow | Wave Pose | 0–7 int · 2 | The atlas row holding the cheer pose (arms up). Zero-indexed. |
| _WaveWidth | Wave Width | 0.5–30 · 5 | Wave band width in world units - how many people cheer at once. |
| _WaveAngle | Wave Direction | 0–360 · 0 | Propagation direction in degrees. 0° = +X, 90° = +Z, 180° = −X, 270° = −Z. |
| _Smoothness | Edge Smoothness | 0–0.3 · 0.05 | Soft fade at the tile edges. Seeing black lines between characters? Raise it to 0.05–0.1. |
| _AlphaClip | Alpha Clip Threshold | 0–1 · 0.3 | Alpha cutoff. Hard-edged atlas: 0.3–0.5. Soft, anti-aliased: 0.1–0.2. |
| _ClipBoundsEnabled | Enable Clip Bounds | toggle · off | Clipping against a world-space box. Off by default - zero cost when unused. |
| _ClipBoundsMin / Max | Clip Bounds Min / Max XYZ | Vector3 | Corners 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
CrowdControllerand 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.
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
TriggerWave() starts a coroutine, so it only runs in Play mode.10Changelog
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.
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.