Search Unity

  1. Unity support for visionOS is now available. Learn more in our blog post.
    Dismiss Notice

Question Modifying vertex colours at runtime, possible to avoid instancing?

Discussion in 'General Graphics' started by art_ganymede, Nov 9, 2023.

  1. art_ganymede

    art_ganymede

    Joined:
    Nov 7, 2021
    Posts:
    28
    I have some code that, on scene load, will have a prop in the scene sample the vertex colours of nearby terrain (the source mesh). See below for code.

    This does work really well in general, but in extreme examples...
    upload_2023-11-9_15-58-59.png

    The first issue Creating a few 1000 mesh instances at scene load creates a huge overhead. My own code doesn't seem to be causing much of the slowdown, mainly all the mesh-instances. This is not a hugely realistic scenario, but then again, I don't want most of the scene load overhead to just be props "colouring themselves in" so to speak.

    I've had a few thoughts here on what I can do around this?
    • Edit material colour, but this creates a material instance which seems to have an even bigger performance impact than mesh instance.
    • Don't do it at runtime, but mesh instances in the editor can get messy quickly, especially with refreshing prefabs etc.
    The second issue is performance, by default all of these plants could be marked "static" and they would be batched together, massively increasing performance.
    However, the batched meshes static produces are read only. As the batching happens before my code runs, the combined meshes cannot be coloured as they don't have read/write enabled.

    You can alter material properties without having the mesh read/write enabled, but this also seems to prevent batching - all I would be changing is:
    Code (CSharp):
    1. var colour = new Color();
    2.         colour.r = sourceColours[nearestVertIndex].r;
    3.         colour.g = sourceColours[nearestVertIndex].g;
    4.         colour.b = sourceColours[nearestVertIndex].b;
    5.         colour.a = 1.0F;
    6.  
    7.         targetMeshRenderer.material.color *= colour;
    Not super sure on the best way to approach this, or if there's something really simple I'm missing?
    I've heard of additonalVertexStreams, and Material Overrides, but I'm not sure if they're supported in the default render pipeline I'm using, doesn't seem to be?

    Final Issue
    If I did pre-compute this, it would balloon the scene size to 100mb+ right?

    Code (CSharp):
    1. using System.Collections;
    2. using System.Collections.Generic;
    3. using UnityEditor;
    4. using UnityEngine;
    5.  
    6. public class VertexColourSampler : MonoBehaviour
    7. {
    8.     public MeshFilter sourceMeshFilter;
    9.     public MeshFilter targetMeshFilter;
    10.  
    11.     private void Start()
    12.     {
    13.         SampleVertexColours(this);
    14.     }
    15.  
    16.     public void SampleVertexColours(VertexColourSampler v)
    17.     {
    18.         if (targetMeshFilter == null)
    19.         {
    20.             Debug.LogWarning($"[{GetType()}] The target mesh is messing.");
    21.         }
    22.  
    23.         if (sourceMeshFilter == null)
    24.         {
    25.             Debug.LogWarning($"[{GetType()}] The source mesh is messing.");
    26.         }
    27.  
    28.         if (!targetMeshFilter.sharedMesh.isReadable)
    29.         {
    30.             Debug.LogWarning($"[{GetType()}] The target mesh is not read/write enabled.");
    31.             return;
    32.         }
    33.  
    34.         if (!sourceMeshFilter.sharedMesh.isReadable)
    35.         {
    36.             Debug.LogWarning($"[{GetType()}] The source mesh is not read/write enabled.");
    37.             return;
    38.         }
    39.  
    40.         var targetVerts = v.targetMeshFilter.sharedMesh.vertices;
    41.         var targetColours = v.targetMeshFilter.sharedMesh.colors;
    42.  
    43.         var sourceVerts = v.sourceMeshFilter.sharedMesh.vertices;
    44.         var sourceColours = v.sourceMeshFilter.sharedMesh.colors;
    45.  
    46.         if (targetColours.Length == 0)
    47.         {
    48.             Debug.LogWarning($"[{GetType()}] The target mesh is missing vertex colour data.");
    49.             return;
    50.         }
    51.  
    52.         if (sourceColours.Length == 0)
    53.         {
    54.             Debug.LogWarning($"[{GetType()}] The source mesh is missing vertex colour data.");
    55.             return;
    56.         }
    57.  
    58.         int nearestVertIndex = 0;
    59.         float nearest = 1000.0F;
    60.  
    61.         for (int j = 0; j < sourceVerts.Length; j++)
    62.         {
    63.             var sourceVert = sourceMeshFilter.gameObject.transform.TransformPoint(sourceVerts[j]);
    64.             var thisDistance = Vector3.Distance(transform.position, sourceVert);
    65.  
    66.             if (thisDistance < nearest)
    67.             {
    68.                 nearestVertIndex = j;
    69.                 nearest = thisDistance;
    70.             }
    71.         }
    72.  
    73.         for (int i = 0; i < targetVerts.Length; i++)
    74.         {
    75.             targetColours[i].r *= sourceColours[nearestVertIndex].r;
    76.             targetColours[i].g *= sourceColours[nearestVertIndex].g;
    77.             targetColours[i].b *= sourceColours[nearestVertIndex].b;
    78.         }
    79.  
    80.         v.targetMeshFilter.mesh.colors = targetColours;
    81.     }
    82. }
    With Batching


    Without batching
     
    Last edited: Nov 9, 2023
  2. art_ganymede

    art_ganymede

    Joined:
    Nov 7, 2021
    Posts:
    28
    For anyone coming across this, I settled on the below.
    The index to sample on the source mesh is precomputed in the editor, using an editor script. Then the actual tinting is done at runtime, and done material property blocks.

    This loads and tints 4000 plant meshes in <1 second, and does not increase the size of the scene.

    Code (CSharp):
    1. using System.Collections;
    2. using System.Collections.Generic;
    3. using UnityEditor;
    4. using UnityEngine;
    5.  
    6. public class VertexColourSampler : MonoBehaviour, IPrecomputable
    7. {
    8.     public MeshFilter sourceMeshFilter;
    9.     public MeshRenderer targetMeshRenderer;
    10.     [Space]
    11.     public int closestSourceVertexIndex;
    12.  
    13.     private void Awake()
    14.     {
    15.         SampleVertexColours(this);
    16.     }
    17.  
    18.     public void SampleVertexColours(VertexColourSampler v)
    19.     {
    20.         if (targetMeshRenderer == null)
    21.         {
    22.             Debug.LogWarning($"[{GetType()}] The target mesh is messing.");
    23.             return;
    24.         }
    25.  
    26.         if (sourceMeshFilter == null)
    27.         {
    28.             Debug.LogWarning($"[{GetType()}] The source mesh is messing.");
    29.             return;
    30.         }
    31.  
    32.         if (!sourceMeshFilter.sharedMesh.isReadable)
    33.         {
    34.             Debug.LogWarning($"[{GetType()}] The source mesh is not read/write enabled.");
    35.             return;
    36.         }
    37.  
    38.         var sourceColours = v.sourceMeshFilter.sharedMesh.colors;
    39.  
    40.         if (sourceColours.Length == 0)
    41.         {
    42.             Debug.LogWarning($"[{GetType()}] The source mesh is missing vertex colour data.");
    43.             return;
    44.         }
    45.  
    46.         var colour = new Color();
    47.         colour.r = sourceColours[closestSourceVertexIndex].r;
    48.         colour.g = sourceColours[closestSourceVertexIndex].g;
    49.         colour.b = sourceColours[closestSourceVertexIndex].b;
    50.         colour.a = 1.0F;
    51.  
    52.         MaterialPropertyBlock mpb = new MaterialPropertyBlock();
    53.         mpb.SetColor("_Color", colour);
    54.         targetMeshRenderer.SetPropertyBlock(mpb);
    55.  
    56.     }
    57.  
    58.     public void Precompute()
    59.     {
    60.         if (sourceMeshFilter == null)
    61.         {
    62.             Debug.LogWarning($"[{GetType()}] The source mesh is messing.");
    63.         }
    64.  
    65.         if (!sourceMeshFilter.sharedMesh.isReadable)
    66.         {
    67.             Debug.LogWarning($"[{GetType()}] The source mesh is not read/write enabled.");
    68.             return;
    69.         }
    70.  
    71.         var sourceVerts = sourceMeshFilter.sharedMesh.vertices;
    72.         var sourceColours = sourceMeshFilter.sharedMesh.colors;
    73.  
    74.         if (sourceColours.Length == 0)
    75.         {
    76.             Debug.LogWarning($"[{GetType()}] The source mesh is missing vertex colour data.");
    77.             return;
    78.         }
    79.  
    80.         int nearestVertIndex = 0;
    81.         float nearest = 1000.0F;
    82.  
    83.         for (int j = 0; j < sourceVerts.Length; j++)
    84.         {
    85.             var sourceVert = sourceMeshFilter.gameObject.transform.TransformPoint(sourceVerts[j]);
    86.             var thisDistance = Vector3.Distance(transform.position, sourceVert);
    87.  
    88.             if (thisDistance < nearest)
    89.             {
    90.                 nearestVertIndex = j;
    91.                 nearest = thisDistance;
    92.             }
    93.         }
    94.  
    95.         closestSourceVertexIndex = nearestVertIndex;
    96.     }
    97. }
     
    Last edited: Nov 9, 2023
  3. arkano22

    arkano22

    Joined:
    Sep 20, 2012
    Posts:
    1,856
    Please tell me you're not using a GameObject for each one of these plants. :eek::p

    Why not use mesh instancing? It's the *perfect* fit for this use case. Doing a few draw calls you'll be able to render tens of thousands of instances of any mesh, with per-instance custom data (such as color tint), with zero overhead from having to create and/or store an inordinate amount of gameObjects and components.
     
    art_ganymede likes this.
  4. art_ganymede

    art_ganymede

    Joined:
    Nov 7, 2021
    Posts:
    28
    I was yes. Though the number of plants in a scene was never going to realistically be more than a few hundred + never update. Might not be ideal though?

    This is really interesting read, I'm not quite sure how well it would fit into the workflow though / ease of positioning / rotating plants, would you still need the same number of objects (empty transforms) to store the position / rotation / scale of the plants anyway?

    Static instancing is actually working fine now after switching to material property blocks, the 4000 plants extreme example get combined to just 15 draw calls.

    This could still be really useful for dense stuff that can be placed procedurally (e.g. grass / clovers)
     
    Last edited: Nov 22, 2023
  5. arkano22

    arkano22

    Joined:
    Sep 20, 2012
    Posts:
    1,856
    You only need a matrix struct to determine position/orientation/scale for each instance. Compared to a fully fledged GameObject, it's very lightweight stuff. No need to have transforms at all, or any components for that matter.

    As for placing them in the editor, you could write a custom tool that lets you paint plants around and store their placement in a scriptable object. Then at runtime you just read the matrix data from the scriptable object and render them directly.

    Using instancing they could be just 4 draw calls (1000 instances per draw call, there's a maximum of 1023 per draw call) without the need to create any objects at runtime. Rule of thumb is: if you don't need per-object logic (movement, AI, physics, gameplay bookkeeping), don't use GameObjects, otherwise you pay extra for something you're not using.

    Regarding batching vs instancing, static batching works best when grouping together a few relatively large objects that use different meshes. Instancing works best for many identical meshes, which is definitely your use case.
     
    Last edited: Nov 22, 2023
  6. art_ganymede

    art_ganymede

    Joined:
    Nov 7, 2021
    Posts:
    28
    Thanks for the info. That does sound interesting, but also quite a large commitment from the editor side in Workflow, viewing things in the editor.

    What kind of performance / memory / storage impact are we looking at having these as GameObjects vs just storing the matrices and rendering instanced? Are there any numbers I can check over? (Say for example, 400-500 objects max).

    I've been looking at a few example projects (from unity themselves and otherwise), and haven't come across anything really like this, most "scenes with things in them" just had a gameobject for everything - So I'm a bit surprised to hear this is considered a bit of a cardinal sin?
     
  7. arkano22

    arkano22

    Joined:
    Sep 20, 2012
    Posts:
    1,856
    Make a scene with 500 GameObjects, then a scene with no GameObjects in it. Profile both for memory/performance, check scene file sizes for storage.

    Personally I’ve never seen a game implement small vegetation (grass, shrubs, low plants) as GameObjects, as it’s just wasteful: they run no logic and do not interact with other objects in the scene. Even Unity’s own terrain system doesn’t use GameObjects for vegetation and recommends to use instanced meshes:
    https://docs.unity3d.com/Manual/terrain-Grass.html

    This said, for “just” 500 plants it might not be worth it to change your workflow. If performance is acceptable for you, I’d move on and go back to this if you need to in the future.
     
    Last edited: Nov 22, 2023