0
\$\begingroup\$

This question is specific to unity:

I want to spawn multiple blocks, each representing a letter. There will be more than one copy of an alphabet.

I have a big texture which contains all the letters in it. I use a prefab to Instance all the Blocks(quads) during Awake(). At runtime, I'd like to assign a texture to these quads (identifying the letter). My code looks like this:

    void Update () {
            // if there is no active block, spawn it
            if (activeBlock == null) {
                this.getNextBlock (ref this.activeBlock);
    }
    protected Block getNextBlock(ref Block block){      
            block = this.deQueueBlock ();
            char letter = LetterMgr.Instance.getNextLetter();
            // based on the letter, index into texture 
            // and assign the texture + indices to this block ?
    }

How can I reuse this one big texture and index into each letter amidst all these blocks (quads) ? I will be rendering this on a mobile, so will need to squeeze all the performance I can. Details:

  • 256 x 256 per letter
  • 8 columns x 4 rows
  • Total Texture Size: 2048 x 2048
  • Total number of blocks: ~100

PS: Each of the letters will be moving and will eventually come to rest. So I will have some (around 6-7) actively moving blocks and others will be stationary.

\$\endgroup\$
8
  • \$\begingroup\$ How many of these are you drawing? There's a simple way (instancing a material for each unique letter and offsetting its texture lookup) that makes 1 draw call per unique letter (ie. all the "E" letters share a draw call, but the "F" letters get drawn in a separate call). There's also a more efficient method to render them all in one call, but it means constructing a quad mesh with particular UVs for each unique letter, so it's a bit more verbose and probably overkill if you're only rendering a few at a time. \$\endgroup\$
    – DMGregory
    Apr 12, 2016 at 13:31
  • \$\begingroup\$ about a hundred, but then this will eventually run on a mobile, so may need to squeeze the code for as much performance as I can. Can you please elaborate on both methods @DMGregory ? \$\endgroup\$ Apr 12, 2016 at 13:33
  • \$\begingroup\$ Sure. Can you show me the layout of your letter texture, so I can be precise about the scales/offsets you'll need? \$\endgroup\$
    – DMGregory
    Apr 12, 2016 at 13:36
  • \$\begingroup\$ each letter is 256x256. 8 columns x 4 rows ( + extra unused space). 2048x2048 texture. i65.tinypic.com/2l95klg.png \$\endgroup\$ Apr 12, 2016 at 13:39
  • \$\begingroup\$ @DMGregory : PS, these letters are not stationary and each are moving differently. Not sure if the universal quad mesh solution will still apply ? \$\endgroup\$ Apr 12, 2016 at 13:41

2 Answers 2

1
\$\begingroup\$

Thanks for your patience, the particle solution below took a bit more fiddling to get working than I'd counted on. ;)

Note that in all of these methods (and the one proposed by lvictorino) the texture is shared - what you're asking for doesn't require duplicating the texture in memory. What can pile up are draw calls, so let's look at how we can minimize those...

Method 1 - Sharing Materials Per Unique Letter

We can improve on 1 draw call per quad by sharing one material instance among all quads sharing a letter.

My preferred approach to this is to keep a cache of variants on the material, and serve from the cache when we've already constructed the variant we need. So we'll construct a new material for the first three letters in "madam" but we'll reuse existing cache entries for the last two.

If we're not using Z-sorting (opaque/alpha-tested materials) then this will make 1 draw call per unique letter, or a maximum of 26 for English text in a single case without punctuation, no matter how many copies of each letter you have.

If we need to use Z-sorting, then your draw calls can be as high as one per letter instance (imagine a stack of AZAZAZAZAZ layered in front of one another - there's no way to batch the As together without drawing a Z out of order), as bad as the naive method, but these pathological cases are relatively rare.

(Also, even the best solutions here can still create many draw calls in this case if layered with other, unrelated materials - so there's only so much we can do)

Here's an example class that does this:

using UnityEngine;
using System.Collections.Generic;

[RequireComponent(typeof(Renderer))]
public class CachedMaterialSlice : MonoBehaviour {

    struct VariantDefinition
    {        
        public Material baseMaterial;
        public byte tilesX;
        public byte tilesY;
        public short index;
    }

    static Dictionary<VariantDefinition, Material> _materialCache = new Dictionary<VariantDefinition, Material>();

    static Material GetMaterialVariant(VariantDefinition definition)
    {
        Material variant;
        if(_materialCache.TryGetValue(definition, out variant) == false)
        {
            variant = Instantiate<Material>(definition.baseMaterial);
            var scale = new Vector2(1f / definition.tilesX, 1f / definition.tilesY);
            variant.SetTextureScale("_MainTex", scale);
            variant.SetTextureOffset("_MainTex", Vector2.Scale(scale, 
                new Vector2(definition.index % definition.tilesX, definition.index / definition.tilesX)));

            _materialCache[definition] = variant;
        }
        //Debug.Log("Cache size: " + _materialCache.Count);
        return variant;
    }

    public byte tilesX = 8;
    public byte tilesY = 8;
    public short index = 0;

    void FetchMaterialVariant()
    {
        var renderer = GetComponent<Renderer>();
        var definition = new VariantDefinition();
        definition.tilesX = tilesX;
        definition.tilesY = tilesY;
        definition.index = index;
        definition.baseMaterial = renderer.sharedMaterial;

        var fetchedMaterial = GetMaterialVariant(definition);
        if (fetchedMaterial != null)
            renderer.sharedMaterial = fetchedMaterial;
    }

    void OnValidate()
    {
        FetchMaterialVariant();
    }

    void OnDeserialize () {
        FetchMaterialVariant();
    }
}

Note that this version never prunes old entries from the cache. If you're using a fixed-size alphabet that's probably not a big deal, but something to keep an eye on.

Method 2: Manipulating UVs

We draw all the quads with a single material, manipulating the UVs of each quad to select just the part of the texture we want.

On the surface this would still be at least 1 draw call per unique letter, since although there's only one material there are many different sets of vertex data (and different transforms per instance), but this situation is very friendly for Dynamic Batching so the engine will very likely automate combining your quads into one mesh for rendering each frame. (Profile to make sure, of course)

Just like Materials, we'll want to cache these Meshes so we only create as many unique instances as are really needed, but I'll elide that here and focus on just the Mesh manipulation bit:

    void UpdateMesh()
    {
        var filter = GetComponent<MeshFilter>();

        if (_mesh == null)
        {
            _mesh = Instantiate<Mesh>(filter.sharedMesh);
            filter.sharedMesh = _mesh;
        }

        Vector2[] uv = new Vector2[4];

        Vector2 scale = new Vector2(1f / tilesX, 1f / tilesY);

        uv[0] = Vector2.Scale(scale, new Vector2(index % tilesX, index / tilesX));
        uv[2] = uv[0]; uv[2].x += scale.x;
        uv[3] = uv[0]; uv[3].y += scale.y;
        uv[1].x = uv[2].x; uv[1].y = uv[3].y;

        _mesh.uv = uv;
    }

This should still give 1 draw call even in the nasty AZAZAZAZ case described earlier - because all the letters use one material, the engine should be able to sort them when constructing the draw call. Interleaving with unrelated materials will still increase your count though.

Method 3: Abuse of Particle Systems

I wrote this version up while I'd briefly forgotten that Dynamic Batching was a thing. So... profile that solution first, as it's very likely going to give equal or better performance for quite a bit less work.

That said, this approach has a certain cuteness... Here we use the fact that Unity's ParticleSystem can draw clouds of thousands of particles drawing from different parts of a shared spritesheet, and we trick that into drawing our quads for us.

First, the class you put on (otherwise empty) letter GameObjects parented to your ParticleSystem:

public class ParticleQuad : MonoBehaviour {

    [SerializeField]
    int _spriteIndex;

    float _particleValue = 0;
    bool _isInList = false;

    public int spriteIndex
    {
        get { return _spriteIndex; }
        set
        {
            _spriteIndex = value;
            _particleValue = _spriteIndex + 0.5f;
        }
    }

    public void MarkRemoved()
    {
        _isInList = false;
    }

    public float GetParticleValue()
    {
        return _particleValue;
    }

    void OnValidate()
    {
        spriteIndex = _spriteIndex;
    }

    void OnEnable()
    {
        if(!_isInList)
            transform.parent.GetComponent<ParticleQuadManager>().Register(this);
        _isInList = true;
    }    
}

Now, configure the ParticleSystem as follows:

  • Enable only the Renderer and Texture Sheet Animation subcomponents. Disable Emission/Shape (we'll be driving these manually)
  • Configure the Texture Sheet Animation subcomponent with the number of rows and columns in your whole texture (include empty space). Keep the curve as its default.
  • Set the Start Color and Start Size to the appropriate values for your quads (variable colours/sizes can be supported, I just haven't shown that functionality here)
  • Set the Max Particles value to the max you'll actually use.

Then attach this script:

using UnityEngine;
using System.Collections.Generic;
using Particle = UnityEngine.ParticleSystem.Particle;

[RequireComponent(typeof(ParticleSystem))]
public class ParticleQuadManager : MonoBehaviour {

    ParticleSystem _system;
    Particle[] _particles;
    public List<ParticleQuad> _quads = new List<ParticleQuad>();
    Particle _blank;
    int _lastParticleCount;

    void OnValidate()
    {
        if (_system == null)
            _system = GetComponent<ParticleSystem>();
        UpdateBlankParticle();
    }

    void UpdateBlankParticle()
    {
        _blank = new Particle();
        _blank.startLifetime = _system.textureSheetAnimation.numTilesX * _system.textureSheetAnimation.numTilesY;
        _blank.startSize = _system.startSize;
        _blank.startColor = _system.startColor;
    }

    void Start()
    {
        _system = GetComponent<ParticleSystem>();
        _particles = new Particle[_system.maxParticles];
        UpdateBlankParticle();                
    }

    public void Register(ParticleQuad quad)
    {
        _quads.Add(quad);
    }

    void LateUpdate()
    {
        // Remove any quads that have been deleted.
        _quads.RemoveAll((quad) => 
        {
            if (quad == null)
                return true;

            if(quad.enabled == false)
            {
                quad.MarkRemoved();
                return true;
            }

            return false;
        });

        // Match all particles to the current quad positions.
        for(int i = 0; i < _quads.Count; i++)
        {
            Particle p = _blank;
            p.position = _quads[i].transform.localPosition;
            p.lifetime = p.startLifetime - _quads[i].GetParticleValue();

            _particles[i] = p;
        }

        // Wipe unneeded particles.
        for(int i = _quads.Count; i < _lastParticleCount; i++)
        {
            Particle p = _blank;
            p.lifetime = float.PositiveInfinity;
            _particles[i] = p;
        }

        _system.SetParticles(_particles, Mathf.Max(_quads.Count, _lastParticleCount));

        _lastParticleCount = _quads.Count;
    }
}

Now the particle versions of the quads will track the movement of the corresponding GameObjects, all drawn in one pass barring any sorting with unrelated materials. A silly way to do it, but it does work. ;) I make no guarantees it performs well - profile and see, but my bet's on the UV modification + Dynamic Batching approach winning.

\$\endgroup\$
5
  • \$\begingroup\$ Thanks a ton for taking the time to answer with such great detail. It was good learning. While I was waiting for your response, I did something similar to choice 2. I create a quad Prefab + attach a material to it with the entire texture on it. When I call Instantiate(quadPrefab), I set the uv to correct values. Will this cause the material to be shared or duplicated across each (100) quads. How can I share a material between all these quads ? \$\endgroup\$ Apr 15, 2016 at 7:09
  • \$\begingroup\$ @brainydexter: if you're setting the UVs on the Mesh like in method 2 then it won't duplicate the material, since you haven't touched the material. It will however duplicate the mesh, so you'll still want some caching mechanism to help you re-use the "E" quad for every "E" instead of making identical copies of the mesh data each time. \$\endgroup\$
    – DMGregory
    Apr 15, 2016 at 11:23
  • \$\begingroup\$ if I share meshes between a alphabet and i move one of the object, will it not reflect in the position of all the other objects representing the same letter ? \$\endgroup\$ Apr 15, 2016 at 20:09
  • \$\begingroup\$ No. Meshes just describe the shape. The Transform component dictates the position/orientation/scale, and each letter instance still has its own Transform even if it shares a mesh with others. \$\endgroup\$
    – DMGregory
    Apr 15, 2016 at 20:11
  • \$\begingroup\$ This is the problem I am running into when I try to share meshes: gamedev.stackexchange.com/questions/119997/… \$\endgroup\$ Apr 17, 2016 at 6:52
0
\$\begingroup\$

Your quads may be game objects and have a MeshRenderer.

MeshRenderer components give you access to the renderer Material. And the Material gives you access to the texture via SetTexture. So once you have Instantiated your new object you can set your Texture doing:

MeshRenderer object_renderer = my_new_game_object.GetComponent<MeshRenderer>();
object_renderer.material.SetTexture(my_texture);
\$\endgroup\$
4
  • \$\begingroup\$ As described in the question, the letters are all in one texture, so you'd want to configure texture offsets instead. \$\endgroup\$
    – DMGregory
    Apr 12, 2016 at 14:51
  • \$\begingroup\$ You can change textures offsets using this : docs.unity3d.com/ScriptReference/… (not sure if you say my answer is out of context - which is probably the case :/ - or if you are asking for more details). \$\endgroup\$
    – lvictorino
    Apr 12, 2016 at 14:53
  • \$\begingroup\$ Yes, each Quad is a prefab and has a MeshRenderer. If I understand this correctly, you are suggesting to create a Texture that is shared by all the blocks. Once a block is instantiated, I somehow set the texture and its offsets into the big texture ? Also, I'm looking at it from minimizing draw calls/performance. If I have 100 objects, we will have 100 draw calls here ? Will they all share texture data or will unity make copies internally ? \$\endgroup\$ Apr 12, 2016 at 15:57
  • \$\begingroup\$ If they all share the same material, it should reduce the number of draw calls (but I can't guarantee that as it's far from my field of expertise). \$\endgroup\$
    – lvictorino
    Apr 12, 2016 at 19:20

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .