Marek Kost

Game developer

While I was exploring n-player Voronoi based split screen on which I have been stuck for a couple of weeks for multiple reasons, I have decided to clean up and release and open source 2 player 2D version under MIT license. Full project made in Unity 2019.4.0f1 can be found on github.

If you are interested in using it in your own project or just checking out the demo, make sure you read the README.

I want to talk a little bit in depth about my implementation, math, shaders and optimizations that make the Unity Dynamic Split a great way to easily set up and use split screen in your 2D projects. I don’t think it’s perfect by any means but it’s quite light weight and perfect starting point if you want to get split screen working without implementing it completely on your own.

Math

I’ve tried to comment out blocks of code to better explain what’s happening and make the code easy(ish) to read at the same time but to sum it up, all that’s happening in update in terms of choosing positions of players is this.

Converting world space player positions to <0, 1> range

Following block of code takes care of converting coordinates for range that is easier to work with and corrects positions according to aspect ratio.

// calculate normalized positions
{
  Vector2 min, max;

  // calculate positions in <0, infinity> range
  for (int i = 0; i < MAX_PLAYERS; i++)
  {
    normalizedPositions[i] = worldPositions[i];
  }

  min = normalizedPositions[0];
  max = normalizedPositions[0];

  for (int i = 1; i < MAX_PLAYERS; i++)
  {
    if (normalizedPositions[i].x < min.x) min.x = normalizedPositions[i].x;
    if (normalizedPositions[i].x > max.x) max.x = normalizedPositions[i].x;
    if (normalizedPositions[i].y < min.y) min.y = normalizedPositions[i].y;
    if (normalizedPositions[i].y > max.y) max.y = normalizedPositions[i].y;
  }

  max -= min;
  for (int i = 0; i < MAX_PLAYERS; i++)
  {
    normalizedPositions[i] -= min;
  }

  // correct positions for screen aspect ratio
  var diff = Vector2.zero;
  if (max.x > max.y * screen.AspectRatio)
  {
    diff.y = ((max.x / screen.AspectRatio) - max.y) / 2;
  }
  else if (max.y > max.x * 1 / screen.AspectRatio)
  {
    diff.x = ((max.y * screen.AspectRatio) - max.x) / 2;
  }

  for (int i = 0; i < MAX_PLAYERS; i++)
  {
    normalizedPositions[i] += diff;
  }
  max += diff * 2;

  // convert to <0, 1> range
  for (int i = 0; i < MAX_PLAYERS; i++)
  {
    normalizedPositions[i] /= max;
  }
}

Merging views

We have 3 modes of screen:

  1. Merged screen
  2. Split screen
  3. In transition – to handle this properly, we need to calculate middle point in between player positions when in merging distance and keep track of merge ratio which is in <0, 1> range where 0 is split and 1 is single screen
// handle merging
{
  activePlayers = 2;
  mergeRatio = 0;

  if (EnableMerging)
  {
    var diff = normalizedPositions[1] - normalizedPositions[0];
    var mergeDistance = Vector2.SqrMagnitude(diff * screen.OrthoSize);
    var realDiff = worldPositions[1] - worldPositions[0];
    var realDistance = Vector2.SqrMagnitude(realDiff);

    var distRatio = realDistance / mergeDistance;
    var smoothingDistance = 1;

    if (distRatio <= 1 + smoothingDistance)
    {
      mergedPosition = Vector2.Lerp(worldPositions[0], worldPositions[1], 0.5f);
      if (distRatio <= 1)
      {
        // screens are merged
        mergeRatio = 1;
        activePlayers = 1;
      }
      else
      {
        // screens are in between merged and split, we need to convert mergeRatio to <0, 1> range,
        // where 0 is split and 1 merged
        mergeRatio = distRatio - 1;
        mergeRatio /= smoothingDistance;
        mergeRatio = 1 - mergeRatio;
      }
    }
  }
}

Rendering

The most important component that makes the rendering work is VoronoiCell.shader and all it does is takes player positions and writes to stencil buffer the index of player who is closest to given position.

You can imagine all we need to do after that is to render all player views into one texture and only render objects with Ref in Stencil block set to player index. We are effectivelly blocking rendering of other things with Mask object under Split Screen Camera so that we prevent rendering things which don’t use Stencil block multiple times.

Post processes

  1. Render VoronoiCell Mask object to get base texture for split line shader
  2. Render SplitLine which is drawn by simple edge detection shader to separate texture
  3. Blend rendered player views (from single texture) and split line into screen texture
  4. Optionally run FXAA
  5. Blit rendered UI on top of screen texture

Optimizations

To achieve the best rendering performance, we are only clearing Split Screen Camera with Solid Color (or you could do skybox) so that UI renders properly and besides that we clear depth buffer of player render texture since color is going to be drawn over regardless.

With the use of stencil buffer we are able to render just the objects that are in the view defined by our VoronoiCell shader and because of that, to render these objects properly, we need to use shader with modified Stencil block.

Stencil {
  Ref [_VoronoiCellsPlayerStencil]
  Comp [_MaskedStencilOp]
}

Stencil block above is taken from MaskedStandard shader. This causes shader to discard pixels that are out of area defined by player index (_VoronoiCellsPlayerStencil) in stencil buffer.

It’s easy enough to use this shader to get started. If you use custom shaders, all you need to to is copy this block and place it inside your SubShader block and everything should render nicely.

In conclusion

If you haven’t already and are interested, please go ahead and download latest package or clone the repository. Hope you find it useful. It’s been fun putting out stuff again and I will try do do more for open source community and post more in the near future.

Post Author: marekkost

Leave a Reply

Your email address will not be published. Required fields are marked *