QuickDraw: GPU-accelerated immediate mode vector graphics renderer [WIP]

What is it?

QuickDraw is an easy to use GPU-accelerated immediate mode renderer for points, lines, icons, simple text, triangles and also more complex geometric shapes. The renderer works both in the editor, including the edit-time, and in the builds. If you need hundreds of thousands of dynamic points and lines in real-time, you are welcome.

Philosophy

  1. The easier to use the better
  • Immediate mode rendering
  • A single line of code should be enough to draw any primitive
  • Little to no setup required for quick start
  1. Performance is a feature (but not at the expense of ease of use)
  • Let the GPU do most of the work (requires graphics capabilities that most of the mobile devices do not support)
  • No managed memory allocations other than at the startup
  1. Should work both in the builds and in the editor, including the edit-time

What is it good for?

  • For applications that need a solution for rendering static or highly dynamic vector graphics, possibly in large amounts, where the result doesn’t need to be fancy-looking at short distances.
  • A must-have for quick prototyping and rich debug graphics. The existing solutions, neither built-in nor alternative ones, don’t offer pleasant experience in this area.

Target platforms

QuickDraw is designed for use on desktops. While other platforms are not supported, it still may work if the device supports the following graphics capabilities (some mobile devices do, an average old phone doesn’t):

  • integer data type in shaders
  • 2D texture arrays
  • geometry shaders
  • at least 4 structured buffer inputs in vertex shaders

Basic usage example

using QuickDraw;

...
void Update()
{
    // A text centered at 'position', default height in meters, default color
    QDraw.Text3D("Hello, World!", position);

    // A line from 'start' to 'end', default width in pixels, default color
    QDraw.Line(start, end);

    // Two points at both ends of the line, default size in pixels
    QDraw.Point(start, Color.cyan);
    QDraw.Point(end, Color.yellow);
}
...

Features

Geometric primitives/shapes

Point, Line, DashedLine, DottedLine, Ray, Arrow, ArrowHead, Pivot, Frustum, Mesh, SolidMesh, Triangle, SolidTriangle, Quad, SolidQuad, Circle, SolidCircle, Wheel, Grid, Arc, DirectionalArc, SolidArc, Cube, SolidCube, Sphere, SolidSphere, Polygon, Polyline, Bounds, Hemisphere, Cylinder, Capsule, Pyramide, Cone

Icons

Icon is just a textured quad positioned somewhere in the world. Icons can always face the camera or be oriented in space. You can specify which icon to draw by its name or its index in the collection of icons.


Text

The text is rendered using a simple ASCII bitmap font and always faces the camera. QDraw.Text3D(...) is probably the easiest way to show some textual or numerical information on screen.

Sizes

Points, icons and lines support sizes specified either in screen pixels or in world units (usually meters). All methods that use world units have “3D” suffix at the end of their names. Pixel Size Multiplier in the settings lets you adjust all the pixel sizes to different screen resolutions and DPIs. Points and Lines also support subpixel width simulated by alpha fading.

Colors and gradients

You can specify the color of the primitives in advance or pass it directly to any of the drawing methods. Lines, triangles and quads also support color gradients.

Performance

All the QDraw.XXX() methods don’t render anything right away, but rather accumulate the information in internal buffers. This makes most of these methods very fast, and also makes it possible to do the actual rendering in just a couple of draw calls at the end of the current frame, which in turn makes the rendering very fast too. Also, as if it wasn’t fast enough, QDraw.Points() and QDraw.Lines() accept pointers to collections of points and lines, so you don’t have to submit them for drawing one by one.

100 000 dynamic lines (only drawing, particle simulation is on pause)

Transformation matrices

Like UnityEngine.Gizmos and UnityEditor.Handles, QuickDraw also supports setting up the current transformation matrix. You can draw your shapes in local coordinates and the transformation defined by the current matrix will be applied automatically.

Standalone Megademo

Megademo download links:

Windows (~15 Mb)
https://drive.google.com/file/d/17zuFJApRJElyipfbgVrCR6ZHFFpAvH14/view
https://dl.dropbox.com/s/sn7imq323zyrnj9/QuickDraw Demo.zip

Android (~10 Mb)
https://drive.google.com/file/d/1bmc05Zc3FXvw5sBy3jR3VCG494dPNFa1/view
https://dl.dropbox.com/s/ii8hwwwj6e5rao1/QuickDraw-Megademo.apk

Screenshots

Todo list

  • Settings window overhaul: font preview, better icons editor, validation of values, warnings/tooltips/help
  • Fix minor technical issues
  • Beautiful documentation

3 Likes

Unity really lacks convenient tools for quick visualization and rich debug graphics. While debugging or prototyping we usually need

  • lines
  • dots to highlight positions/intersections
  • text labels to be able to see vertex numbers, object names, whatever…
  • grids to visualize planes or cells
  • circles, rectangles and bounding boxes to visualize some areas
  • arrows for normals and directions
  • …

In the editor the options are limited to UnityEngine.Debug.DrawLine/DrawRay that can only draw thin lines, UnityEngine.Gizmos that can draw lines, cubes, spheres, icons and that’s pretty much it. And there’s also UnityEditor.Handles that could be much more useful if it didn’t require a custom editor class with a lot of boilerplate code in order to use its capabilities.

And if there is a need to put debug graphics inside a standalone build, there is just no convenient tool for that. UnityEngine.LineRenderer is not really an option and all the editor stuff doesn’t work in the builds. One day I ended up using a particle system with a texture atlas with numbers to debug lighting for my old voxel engine:
Image

So, I have been developing QuickDraw for quite a while now to cover my personal needs and I can say that at this point it covers them pretty well.

A couple of examples:

Here it’s used to visualize the results of a triangulation algorithm implementation. Everything is rendered by QuickDraw except the background plane that is just a regular plane with a texture.
Image


QuickDraw couldn’t render text at that time, so I had to mark different vertices with different colors and to remember which color corresponded to the first vertex, the second etc - just to be able to debug the problem if something went wrong.

Here I was messing with a fluid simulation and used QuickDraw in its early days to render water particles and forces between them. Compared to the simulation, the rendering ended up almost free.
Video

Code

using UnityEngine;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class SimpleMeshInspector : DemoBase
    {
        [Header("Mesh")]
        public Mesh mesh = null;

        [Header("Vertices")]
        public Color vertexColor = Color.yellow;
        public Color vertexLineColor = new Color(1, 1, 0, .05f);
        public float vertexPointSize = 10;

        [Header("Triangles")]
        public float edgeWidth = 2f;
        public Color arcColor = Color.cyan;
        public Color triangleIndexColor = Color.cyan;

        [Header("Normals")]
        public Color normalColor = new Color(0, 1, 0, .5f);

        private Mesh cachedMesh;
        private Vector3[] positions;
        private Vector3[] normals;
        private int[] indices;

        private void Update()
        {
            if (Application.isPlaying)
            {
                ControlCamera();
            }

            if (mesh == null)
            {
                return;
            }

            if (cachedMesh != mesh)
            {
                positions = mesh.vertices;
                normals = mesh.normals;
                indices = mesh.triangles;
                cachedMesh = mesh;
            }

            QDraw.Matrix = transform.localToWorldMatrix;
            using (QDraw.Push())
            {
                QDraw.LineWidth = edgeWidth;
                QDraw.Mesh(mesh);
            }

            for (int i = 0; i < positions.Length; i++)
            {
                QDraw.Point(positions[i], vertexPointSize, vertexColor);
            }

            for (int i = 0; i < indices.Length; i += 3)
            {
                var ia = indices[i + 0];
                var ib = indices[i + 1];
                var ic = indices[i + 2];

                var pa = positions[ia];
                var pb = positions[ib];
                var pc = positions[ic];

                var (center, radius) = GetInscribedCircle();
                var normal = Vector3.Cross(pa - pb, pb - pc).normalized;
                var arcRadius = radius / 2;

                DrawTriangle();

                DrawVertexIndex(ia);
                DrawVertexIndex(ib);
                DrawVertexIndex(ic);

                void DrawTriangle()
                {
                    var textHeight = radius / 4;
                    QDraw.Text3D(i / 3, center, textHeight, triangleIndexColor);

                    var angle = Vector3.SignedAngle(pa - center, pc - center, normal) * Mathf.Deg2Rad;
                    if (angle < 0)
                    {
                        angle += 2 * Mathf.PI;
                    }

                    QDraw.DirectionalArc(center, normal, pa - center, arcRadius, angle, arcColor);
                }

                void DrawVertexIndex(int index)
                {
                    var position = positions[index];
                    QDraw.Arrow(position, position + normals[index] * radius, normalColor);

                    var direction = (position - center).normalized;
                    var labelPosition = center + direction * arcRadius * 1.2f;
                    QDraw.Line(labelPosition, position, vertexLineColor);
              
                    var textHeight = radius / 5;
                    QDraw.Text3D(index, labelPosition, textHeight, vertexColor);
                }

                (Vector3 center, float radius) GetInscribedCircle()
                {
                    var a = (pc - pb).magnitude;
                    var b = (pc - pa).magnitude;
                    var c = (pb - pa).magnitude;

                    var s = (a + b + c) / 2;
                    var area = Mathf.Sqrt(s * (s - a) * (s - b) * (s - c));

                    var x = (a * pa.x + b * pb.x + c * pc.x) / (a + b + c);
                    var y = (a * pa.y + b * pb.y + c * pc.y) / (a + b + c);
                    var z = (a * pa.z + b * pb.z + c * pc.z) / (a + b + c);

                    return (new Vector3(x, y, z), area / s);
                }
            }
        }
    }
}

Code

using UnityEngine;
using static UnityEngine.Mathf;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class MobiusStripDemo : DemoBase
    {
        [Header("Möbius Strip")]
        [Range(5, 500)] public int sectors = 100;
        [Range(2, 200)] public int segments = 20;
        [Range(0, 1)] public float speed = .1f;

        [Header("Visuals")]
        [Range(0, .01f)] public float lineWidth3D = 0.003f;
        [Range(0, 1)] public float opacity = .2f;
        public bool doubleSided = false;

        private float time = 0;

        private void Update()
        {
            if (Application.isPlaying)
            {
                ControlCamera();
                time += Time.deltaTime * speed;
            }

            QDraw.Matrix = transform.localToWorldMatrix;
            QDraw.LineWidth3D = lineWidth3D;
            QDraw.DoubleSidedGeometry = doubleSided;

            for (int s = 0; s < sectors; s++)
            {
                for (int t = 0; t < segments; t++)
                {
                    DrawQuad(s, t);
                }
            }
        }

        private void DrawQuad(int s, int t)
        {
            var (p0, color0) = GetPosition(s, t);
            var (p1, color1) = GetPosition(s + 1, t);
            var (p2, color2) = GetPosition(s + 1, t + 1);
            var (p3, color3) = GetPosition(s, t + 1);

            QDraw.SolidTriangle(p0, p1, p2, color0.WithAlpha(opacity), color1.WithAlpha(opacity), color2.WithAlpha(opacity));
            QDraw.SolidTriangle(p0, p2, p3, color0.WithAlpha(opacity), color2.WithAlpha(opacity), color3.WithAlpha(opacity));

            QDraw.Line3D(p0, p1, color0, color1);
            QDraw.Line3D(p1, p2, color1, color2);
            QDraw.Line3D(p2, p3, color2, color3);
            QDraw.Line3D(p3, p0, color3, color0);
        }

        private (Vector3, Color) GetPosition(float s, float t)
        {
            s = s * 2f / sectors * PI + time;
            t = t * 2f / segments - 1f;

            var x = (1 + t / 2 * Cos(s / 2)) * Cos(s);
            var y = (1 + t / 2 * Cos(s / 2)) * Sin(s);
            var z = t / 2 * Sin(s / 2);
            var color = Color.HSVToRGB(z + .5f, 1, 1);

            return (new Vector3(x, y, z), color);
        }
    }

    internal static class ColorExtensions
    {
        public static Color WithAlpha(this Color color, float alpha)
        {
            return new Color(color.r, color.g, color.b, alpha);
        }
    }
}

Code

using UnityEngine;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class LorenzAttractorDemo : DemoBase
    {
        [Header("Shape")]
        [Range(.0001f, .01f)] public float step = .005f;
        [Range(1, 20)] public float a = 10f;
        [Range(10, 40)] public float b = 28f;
        [Range(0, 20)] public float c = 8 / 3f;

        [Header("Visuals")]
        [Range(0, .1f)] public float lineWidth3D = .02f;
        [Range(0, 1000)] public float curveLength = 0;

        private readonly Vector3 center = new Vector3(.2f, .1f, 24f);

        private void Update()
        {
            if (Application.isPlaying)
            {
                ControlCamera();
                curveLength += step * Time.deltaTime * 100;
            }

            QDraw.Matrix = transform.localToWorldMatrix;
            QDraw.Matrix *= Matrix4x4.Translate(-center);
            QDraw.LineWidth3D = lineWidth3D;

            DrawLorenzAttractor();
        }

        private void DrawLorenzAttractor()
        {
            var p0 = new Vector3(.1f, 0, 0);
            var color0 = Color.clear;

            float length = curveLength;
            while (length > 0)
            {
                Vector3 p1;
                p1.x = p0.x + step * a * (p0.y - p0.x);
                p1.y = p0.y + step * (p0.x * (b - p0.z) - p0.y);
                p1.z = p0.z + step * (p0.x * p0.y - c * p0.z);

                var hue = Mathf.Repeat((p1 - center).sqrMagnitude * .0008f, 1);
                var color1 = Color.HSVToRGB(hue, 1, 1);

                QDraw.Line3D(p0, p1, color0, color1);

                p0 = p1;
                color0 = color1;
                length -= step;
            }

            QDraw.Point3D(p0, lineWidth3D * 5, color0);
        }
    }
}

Code

using System;
using UnityEngine;
using Random = UnityEngine.Random;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class FractalTreeDemo : DemoBase
    {
        [Header("Geometry")]
        [Range(1, 7)] public int segments = 6;
        [Range(0, 90)] public float angle = 50;
        [Range(.1f, .5f)] public float minBranchLength = .5f;
        [Range(.5f, 1)] public float maxBranchLength = .75f;
        [Range(0, .5f)] public float windForce = .05f;

        [Header("Lines")]
        public Color lineColor = new Color(.3f, .2f, .07f, 1);
        [Range(0, .1f)] public float lineWidth3D = .03f;

        [Header("Icons")]
        public Color iconColor = new Color(0.45f, 1f, 0, 1);
        [Range(0, .2f)] public float iconSize3D = .1f;

        private Bounds bounds;
        private Vector3 targetPosition = new Vector3(0, -1.5f, 0);
        private Vector3 velocity = Vector3.zero;
        private int seed;

        private void Start()
        {
            if (Application.isPlaying)
            {
                InvokeRepeating(nameof(ChangeSeed), 0, 5.017f);
            }
        }

        private void ChangeSeed()
        {
            seed = DateTime.Now.Millisecond;
            iconColor = Color.HSVToRGB(Random.value, 1, 1);
        }

        private void Update()
        {
            Random.InitState(seed);

            QDraw.Matrix = transform.localToWorldMatrix;
            QDraw.Matrix *= Matrix4x4.Translate(targetPosition);
            QDraw.LineWidth3D = lineWidth3D;
            QDraw.PointSize3D = iconSize3D;

            QDraw.SolidCircle(Vector3.zero, Vector3.up, lineWidth3D * 10, new Color(lineColor.r, lineColor.g, lineColor.b, .25f));
            QDraw.Wheel(Vector3.zero, Vector3.up, lineWidth3D * 10, 6, lineColor);

            bounds = new Bounds(Vector3.zero, Vector3.zero);
            DrawFractalTree(Vector3.zero, Vector3.up, 1);
            targetPosition = Vector3.SmoothDamp(targetPosition, -bounds.center, ref velocity, .5f);
        }

        private void DrawFractalTree(Vector3 fromPosition, Vector3 toPosition, int depth)
        {
            var wind = Mathf.PerlinNoise(toPosition.z + Time.time, toPosition.x);
            toPosition.z += wind * windForce;

            bounds.Encapsulate(toPosition);

            if (depth > segments)
            {
                return;
            }

            var direction = toPosition - fromPosition;
            var depthFactor = depth / (float) (segments);

            var trunkWidth = lineWidth3D * Mathf.Max(1 - depthFactor, .1f);
            QDraw.Line3D(fromPosition, toPosition, trunkWidth, lineColor);

            Color.RGBToHSV(iconColor, out var hue, out var satudation, out var value);
            hue *= Mathf.PerlinNoise(toPosition.x, toPosition.z);
            value *= depthFactor;
            var leafColor = Color.HSVToRGB(hue, satudation, value);
            QDraw.Icon3D("chestnut-leaf", toPosition, Vector3.back, direction, iconSize3D, leafColor);

            DrawFractalTree(toPosition, toPosition + Quaternion.Euler(-angle, 0, 0) * direction * Random.Range(minBranchLength, maxBranchLength), depth + 1);
            DrawFractalTree(toPosition, toPosition + Quaternion.Euler(angle, 0, 0) * direction * Random.Range(minBranchLength, maxBranchLength), depth + 1);
            DrawFractalTree(toPosition, toPosition + Quaternion.Euler(0, 0, -angle) * direction * Random.Range(minBranchLength, maxBranchLength), depth + 1);
            DrawFractalTree(toPosition, toPosition + Quaternion.Euler(0, 0, angle) * direction * Random.Range(minBranchLength, maxBranchLength), depth + 1);
        }
    }
}

Code

using System.Collections.Generic;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class FancyMeshDemo : MonoBehaviour
    {
        [Header("Geometry")]
        public Mesh mesh = null;
        [Range(1, 20)] public int divisor = 20;

        [Header("Noise Settings")]
        [Range(1, 4)] public int octaves = 3;
        [Range(0, 5)] public float frequency = 1;
        [Range(0, 1)] public float amplitude = .3f;

        [Header("Visuals")]
        [Range(0, .01f)] public float lineWidth3D = .002f;
        [Range(0, 1)] public float speed = .1f;

        internal static readonly SimplexNoise noiseX = new SimplexNoise(1);
        internal static readonly SimplexNoise noiseY = new SimplexNoise(2);
        internal static readonly SimplexNoise noiseZ = new SimplexNoise(3);

        private Mesh cachedMesh;
        private NativeArray<Vector3> points;
        private NativeArray<Color32> colors;
        private float time;

        private void Update()
        {
            if (mesh == null)
            {
                return;
            }

            if (cachedMesh != mesh)
            {
                cachedMesh = mesh;
                GenerateLines(mesh);
            }

            QDraw.Matrix = transform.localToWorldMatrix;
            QDraw.LineWidth3D = lineWidth3D;

            if (Application.isPlaying)
            {
                time += Time.deltaTime * speed;
            }

            Draw();
        }

        private void Draw()
        {
            var distorted = new NativeArray<Vector3>(points.Length, Allocator.TempJob);

            var job = new DistortJob()
            {
                originalPositions = points,
                distortedPositions = distorted,
                colors = colors,
                octaves = octaves,
                frequency = frequency,
                amplitude = amplitude,
                time = time,
            };
            var handle = job.Schedule(points.Length, 64);
            handle.Complete();

            for (var i = 0; i < distorted.Length; i += 2)
            {
                QDraw.Line3D(distorted[i], distorted[i + 1], colors[i], colors[i + 1]);
            }

            distorted.Dispose();
        }

        private void OnDisable()
        {
            if (points.IsCreated)
            {
                points.Dispose();
                colors.Dispose();
            }
        }

        private void GenerateLines(Mesh mesh)
        {
            var positions = mesh.vertices;
            var indices = mesh.triangles;

            var segmentEndPoints = new List<Vector3>();
            var processedEdges = new List<(int, int)>();

            for (var i = 0; i < indices.Length; i += 3)
            {
                var ia = indices[i + 0];
                var ib = indices[i + 1];
                var ic = indices[i + 2];

                AddEdge(ia, ib);
                AddEdge(ib, ic);
                AddEdge(ic, ia);
            }

            if (points.IsCreated)
            {
                points.Dispose();
                colors.Dispose();
            }

            points = new NativeArray<Vector3>(segmentEndPoints.Count, Allocator.Persistent);
            colors = new NativeArray<Color32>(segmentEndPoints.Count, Allocator.Persistent);
            for (var i = 0; i < segmentEndPoints.Count; i++)
            {
                points[i] = segmentEndPoints[i];
            }

            void AddEdge(int indexFrom, int indexTo)
            {
                if (indexFrom > indexTo)
                {
                    var temp = indexTo;
                    indexTo = indexFrom;
                    indexFrom = temp;
                }

                if (processedEdges.Contains((indexFrom, indexTo)) == false)
                {
                    processedEdges.Add((indexFrom, indexTo));
                    segmentEndPoints.AddRange(SplitIntoSegments(positions[indexFrom], positions[indexTo]));
                }
            }

            IEnumerable<Vector3> SplitIntoSegments(Vector3 from, Vector3 to)
            {
                var t = 0f;
                var dt = 1f / divisor;
                for (var i = 0; i < divisor; i++)
                {
                    yield return Vector3.LerpUnclamped(from, to, t);
                    yield return Vector3.LerpUnclamped(from, to, t + dt);
                    t += dt;
                }

                yield return Vector3.LerpUnclamped(from, to, 1 - dt);
                yield return to;
            }
        }
    }

    internal struct DistortJob : IJobParallelFor
    {
        public NativeArray<Vector3> originalPositions;
        public NativeArray<Vector3> distortedPositions;
        public NativeArray<Color32> colors;
        public int octaves;
        public float frequency;
        public float amplitude;
        public float time;

        public void Execute(int index)
        {
            var original = originalPositions[index];
            var distortion = CalculateDistortion(original);
            distortedPositions[index] = original + distortion;

            var diff = distortion.magnitude * 10;
            var color0 = Color.HSVToRGB(Mathf.Repeat(diff, 1), 1, 1);
            var color = Color.Lerp(Color.white, color0, diff);
            colors[index] = color;
        }
        private Vector3 CalculateDistortion(Vector3 position)
        {
            var dx = FancyMeshDemo.noiseX.GetFractalValue(position.x + time, position.y, position.z, octaves, frequency, amplitude);
            var dy = FancyMeshDemo.noiseY.GetFractalValue(position.x, position.y + time, position.z, octaves, frequency, amplitude);
            var dz = FancyMeshDemo.noiseZ.GetFractalValue(position.x, position.y, position.z + time, octaves, frequency, amplitude);
            return new Vector3(dx, dy, dz);
        }
    }
}

Code

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static UnityEngine.Mathf;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class LoveTunnelDemo : MonoBehaviour
    {
 
        [Header("Tunnel")]
        [Range(0, 200)] public int tunnelLength = 100;
        public AnimationCurve tunnelOpacityCurve = AnimationCurve.Linear(0, 1, 0, 1);
        [Range(0, .05f)] public float interFrameStep = .0025f;
        public float curveScale = 10f;
        [Range(0, 1)] public float targetSpeed = .1f;
        [Range(0, 5)] public float timeScale = 1;

        private readonly SimplexNoise noiseX = new SimplexNoise(0);
        private readonly SimplexNoise noiseY = new SimplexNoise(1);
        private readonly SimplexNoise noiseZ = new SimplexNoise(2);

        [Header("Tunnel Noise")]
        [Range(0.01f, 1)] public float frequency = 1;
        public float amplitude = 180;
        [Range(1, 4)] public int octaves = 2;

        [Header("Heart")]
        public float scale = .05f;
        [Range(0, 100)] public int fillPercent = 100;
        [ColorUsage(false, true)] public Color loveColor = Color.red;
        [ColorUsage(false, true)] public Color hateColor = Color.blue;
        [Range(0, 1)] public float maxColorDeviation = 1;

        [Header("Animation")]
        public bool useHeartBeat = true;
        public float heartScale = 1f;
        public AnimationCurve heartScaleCurve = AnimationCurve.Constant(0, 0, 1);

        [Space]
        public float saturationFrequency = .01f;
        public AnimationCurve saturationCurve = AnimationCurve.Linear(0, 0, 1, 1);
        [Range(0, 1)] public float saturation = 1f;

        [Space]
        public float opacityFrequency = .01f;
        public AnimationCurve opacityCurve = AnimationCurve.Linear(0, 0, 1, 1);
        [Range(0, 1)] public float fillOpacity = .2f;

        [Space]
        public float lineWidthFrequency = .1f;
        public AnimationCurve lineWidthCurve = AnimationCurve.Linear(0, 0, 1, 1);
        [Range(0, 10)] public float lineWidth = 5;

        private float currentTime = 0;
        private float currentPosition = 0;
        private float currentSpeed = .01f;
        private float heartBpm;
        private float heartBeatTimeOffset = 0;
        private bool drawTunnel = true;

        private static readonly List<Vector3> heartShape = new List<Vector3>();

        private void Start()
        {
            StartCoroutine(Animate());
        }

        private IEnumerator Animate()
        {
            if (Application.isPlaying == false)
            {
                yield break;
            }

            const int QUALITY = 100;
            const float TIMEOUT = 2f;
            const float FILL_OPACITY = .15f;

            drawTunnel = false;

            ApplyHeartBeatAnimation();
            ApplySaturationAnimation(0);
            ApplySolidOpacityAnimation(0);
            ApplyLineWidthAnimation(0);

            QDraw.LineWidth = 20;

            yield return new WaitForSecondsRealtime(2);

            var timeout = TIMEOUT;
            var currentTime = 0f;
            while (currentTime <= timeout)
            {
                currentTime += Time.deltaTime;
                var t = currentTime / timeout;

                using (QDraw.Push())
                {
                    QDraw.Matrix = Matrix4x4.Translate(new Vector3(0, 0, 30));
                    DrawHeart(QUALITY, heartScale, (int)(t * 100), hateColor, Color.clear, 1, 0, 0);
                }

                yield return null;
            }

            timeout = TIMEOUT;
            currentTime = 0f;
            while (currentTime <= timeout)
            {
                currentTime += Time.deltaTime;
                var t = currentTime / timeout;

                using (QDraw.Push())
                {
                    QDraw.Matrix = Matrix4x4.Translate(new Vector3(0, 0, 30));
                    DrawHeart(QUALITY, heartScale, 100 - (int)(t * 100), hateColor, hateColor, 1, 0, FILL_OPACITY);
                }

                yield return null;
            }

            timeout = TIMEOUT;
            currentTime = 0f;
            while (currentTime <= timeout)
            {
                currentTime += Time.deltaTime;
                var t = currentTime / timeout;

                using (QDraw.Push())
                {
                    QDraw.Matrix = Matrix4x4.Translate(new Vector3(0, 0, 30));
                    DrawHeart(QUALITY, heartScale, (int)(t * 100), loveColor, hateColor, 1, FILL_OPACITY, FILL_OPACITY);
                }

                yield return null;
            }

            timeout = TIMEOUT;
            currentTime = 0f;
            var targetLength = tunnelLength;
            var targetSpeed2 = this.targetSpeed;
            while (currentTime <= timeout)
            {
                ApplyHeartBeatAnimation();

                currentTime += Time.deltaTime;
                var t = currentTime / timeout;

                using (QDraw.Push())
                {
                    QDraw.Matrix = Matrix4x4.TRS(new Vector3(0, 0, 30), Quaternion.identity, Vector3.one * Remap(0, 1, 1, 2, t));
                    DrawHeart(QUALITY, heartScale, 100, loveColor, hateColor, Remap(0, 1, 1, 0, t), FILL_OPACITY, FILL_OPACITY);
                }

                tunnelLength = (int)Remap(0, 1, 1, targetLength, t);
                currentSpeed = (int)Remap(0, 1, 0, targetSpeed2, t);
                DrawTunnel();

                yield return null;
            }

            drawTunnel = true;
        }


        private void Update()
        {
            if (drawTunnel == false)
            {
                return;
            }

            if (Application.isPlaying)
            {
                currentTime += Time.deltaTime * timeScale;
                currentPosition += currentSpeed * Time.deltaTime * timeScale;

                if (Input.GetKeyDown(KeyCode.Space))
                {
                    BpmMeter.AddBeat();
                    heartBpm = BpmMeter.CalculateBeatsPerMinute();
                    heartBeatTimeOffset = Time.unscaledTime;
                }

                if (Input.GetKeyDown(KeyCode.DownArrow))
                {
                    heartBeatTimeOffset -= .1f;
                }
                if (Input.GetKeyDown(KeyCode.UpArrow))
                {
                    heartBeatTimeOffset += .1f;
                }
            }

            QDraw.VisibleThroughObstacles = true;
            QDraw.Tint = new Color(1, 1, 1, .8f);
            QDraw.LineWidth = lineWidth;

            if (useHeartBeat)
            {
                ApplyHeartBeatAnimation();
            }
            ApplySaturationAnimation(currentTime);
            ApplySolidOpacityAnimation(currentTime);
            ApplyLineWidthAnimation(currentTime);

            DrawTunnel();
        }

        private Vector3 currentUpVector = Vector3.up;
        private Vector3 currentForwardVector = Vector3.forward;
        private Vector3 targetUpVector = Vector3.up;
        private Vector3 targetForwardVector = Vector3.forward;
        private Vector3 upVectorVelocity = Vector3.zero;
        private Vector3 forwardVectorVelocity = Vector3.zero;

        private void DrawTunnel()
        {
            var deltaStep = currentPosition % interFrameStep;
            var firstPosition = currentPosition - deltaStep;

            var (rootPosition, _, currentSpeedFactor) = GetFrameTransform(currentPosition);
            currentSpeed = targetSpeed / currentSpeedFactor * heartBpm / 60;

            var (_, rootRotation, _) = GetFrameTransform(currentPosition + interFrameStep * 5);
            targetUpVector = rootRotation * Vector3.up;
            targetForwardVector = rootRotation * Vector3.forward;
            currentUpVector = Vector3.SmoothDamp(currentUpVector, targetUpVector, ref upVectorVelocity, .3f);
            currentForwardVector = Vector3.SmoothDamp(currentForwardVector, targetForwardVector, ref forwardVectorVelocity, .3f);
            rootRotation = Quaternion.LookRotation(currentForwardVector, currentUpVector);

            QDraw.Matrix = Matrix4x4.TRS(rootPosition, rootRotation, Vector3.one).inverse;

            for (var i = 0; i < tunnelLength; i++)
            {
                var framePosition = firstPosition + i * interFrameStep;
                var opacity = tunnelOpacityCurve.Evaluate(Remap(currentPosition, firstPosition + 10 * interFrameStep, 0, 1, framePosition));

                var bpmPeriod = 60 / heartBpm;
                fillPercent = Clamp(RoundToInt(Remap(0, 1, 0, 100, PingPong(framePosition + Time.unscaledTime - heartBeatTimeOffset, bpmPeriod * 2))), 0, 100);

                using (QDraw.Push())
                {
                    DrawTunnelFrame(framePosition, opacity);
                }
            }

            void DrawTunnelFrame(float framePosition, float frameOpacity)
            {
                var colorOffset = maxColorDeviation * (PerlinNoise(framePosition * .2f, currentTime * .01f) - .5f);
                var loveColor = ChangeColor(this.loveColor, colorOffset);
                var hateColor = ChangeColor(this.hateColor, colorOffset);
                var pointCount = FloorToInt(6 + 94 * PingPong(framePosition * 2, 1));

                var (position, rotation, _) = GetFrameTransform(framePosition);
                QDraw.Matrix *= Matrix4x4.TRS(position, rotation, Vector3.one);
                DrawHeart(pointCount, heartScale, fillPercent, loveColor, hateColor, frameOpacity, fillOpacity, fillOpacity);
            }

            (Vector3 position, Quaternion rotation, float speed) GetFrameTransform(float t)
            {
                var translation = GetCurvePoint(t);
                var ahead = GetCurveDerivative(t);
                var normal = GetCurveSecondDerivative(t);
                var rotation = Quaternion.LookRotation(ahead, normal);
                return (translation, rotation, ahead.magnitude / curveScale);
            }

            Vector3 GetCurvePoint(float t)
            {
                var x = noiseX.GetFractalValue(t, 0, octaves, frequency, amplitude);
                var y = noiseY.GetFractalValue(t, 1, octaves, frequency, amplitude);
                var z = noiseZ.GetFractalValue(t, 2, octaves, frequency, amplitude);
                return new Vector3(x, y, z) * curveScale;
            }

            Vector3 GetCurveDerivative(float t)
            {
                const float DELTA = .01f;
                return (GetCurvePoint(t + DELTA) - GetCurvePoint(t - DELTA)) / (2 * DELTA);
            }

            Vector3 GetCurveSecondDerivative(float t)
            {
                const float DELTA = .01f;
                return (GetCurveDerivative(t + DELTA) - GetCurveDerivative(t - DELTA)) / (2 * DELTA);
            }
        }

        private void DrawHeart(int vertexCount, float scale, int fillPercent, Color loveColor, Color hateColor, float opacity, float loveFillOpacity, float hateFillOpacity)
        {
            heartShape.Clear();
            heartShape.AddRange(GenerateHeartShape(vertexCount, scale * this.scale));

            var totalTriangles = heartShape.Count - 3;
            var hotTriangles = RoundToInt(totalTriangles / 100f * fillPercent);
            var coldTriangles = totalTriangles - hotTriangles;

            loveColor *= opacity;
            hateColor *= opacity;

            var solidLoveColor = loveColor;
            solidLoveColor.a *= loveFillOpacity;

            var solidHateColor = hateColor;
            solidHateColor.a *= hateFillOpacity;

            for (var i = 2; i < heartShape.Count; i++)
            {
                var color = i - 2 < hotTriangles ? solidLoveColor : solidHateColor;
                QDraw.SolidTriangle(heartShape[0], heartShape[i], heartShape[i - 1], color);
            }

            if (coldTriangles > 0)
            {
                var points = coldTriangles + 2;
                if (hotTriangles == 0)
                {
                    points++;
                }

                QDraw.Polyline(heartShape, heartShape.Count - points, points, hateColor);
            }

            if (hotTriangles > 0)
            {
                var points = hotTriangles + 2;
                if (coldTriangles == 0)
                {
                    points++;
                }

                QDraw.Polyline(heartShape, 0, points, loveColor);
            }
        }

        private static float Remap(float fromMin, float fromMax, float toMin, float toMax, float value)
        {
            var t = InverseLerp(fromMin, fromMax, value);
            return Lerp(toMin, toMax, t);
        }

        private void ApplyHeartBeatAnimation()
        {
            heartScale = heartScaleCurve.Evaluate((Time.unscaledTime - heartBeatTimeOffset) * heartBpm / 60f);
        }

        private void ApplySaturationAnimation(float t)
        {
            var value = PerlinNoise(t * saturationFrequency, .2f);
            saturation = saturationCurve.Evaluate(value);
        }

        private void ApplySolidOpacityAnimation(float t)
        {
            var value = PerlinNoise(t * opacityFrequency, 10);
            fillOpacity = opacityCurve.Evaluate(value);
        }

        private void ApplyLineWidthAnimation(float t)
        {
            var value = PerlinNoise(t * lineWidthFrequency, 5);
            lineWidth = lineWidthCurve.Evaluate(value);
        }

        private Color ChangeColor(Color color, float amount)
        {
            Color.RGBToHSV(color, out var h, out _, out var v);
            var s = saturation;
            h = Repeat(h + amount, 1);
            var result = Color.HSVToRGB(h, s, v);
            result.a = color.a;
            return result;
        }

        private static IEnumerable<Vector3> GenerateHeartShape(int count, float scale)
        {
            for (var i = 0; i < count; i++)
            {
                var t = i / (count - 1f) * 2 * PI;
                var x = -16 * Sin(t) * Sin(t) * Sin(t);
                var y = 13 * Cos(t) - 5 * Cos(2 * t) - 2 * Cos(3 * t) - Cos(4 * t);
                var p = new Vector3(x, y, 0) * scale;
                yield return p;
            }
        }
    }
}

The sample code is “a bit” messy right now. I have just finished putting all the things together. The heart shape is procedurally generated and drawn using two methods: QDraw.SolidTriangle() and QDraw.Polyline().

It took me a couple of hours to get the first version working, and then a couple of evenings to figure out what exactly do I want to get as a result.

What I really like about QuickDraw is you don’t have to write boilerplate to visualize what your code is doing. No hitting Play button, no writing custom editor extensions, no OnDrawGizmos. And also no need to write to the console - QDraw.Text3D() can show debug information on the screen in real time at the exact world position it relates to.

1 Like

Screenshots

This demo draws ~10k arrows (~20k lines) and 50k points. The profiler shows that all the drawing-related code takes less than 0,5 ms in total in the editor. The rest of the time is taken by simplex noise computation and particle simulation.

Main Script Source

using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Jobs;
using UnityEngine;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class VectorFieldDemo : DemoBase
    {
        #region Properties

        [Header("Vector Field")]
        [Range(1, 20)] public float areaSize = 10;
        [Range(2, 200)] public int fieldResolution = 100;
        [Range(0, 1)] public float fieldOpacity = 1;
        public Gradient gradient = null;
        public Color boundsColor = new Color(.5f, .5f, .5f, .5f);

        [Header("Noise")]
        [Range(0, 1)] public float frequency = .25f;
        [Range(0, 1)] public float timeScale = .1f;

        [Header("Arrows")]
        public float arrowWidth = 0.005f;
        public float relativeArrowWidth = .15f;

        [Header("Particles")]
        [Range(1, 100_000)] public int particleCount = 50_000;
        [Range(0, 10)] public float particleWidth = 5;
        [Range(0, 5)] public float particleSpeed = 1;
        [Range(0, 1)] public float particleOpacity = 1;

        [Header("Boats")]
        public bool boatsEnabled = true;
        [Range(1, 10)] public int boatCount = 3;

        private float time;
        private float deltaTime;
        private Bounds bounds;

        internal static readonly SimplexNoise noise = new SimplexNoise();
        internal static readonly System.Random random = new System.Random();
        internal static Gradient gradientStatic;
        private NativeArray<Line> arrows;
        private NativeArray<Vector3> particles;
        private NativeArray<Color32> colors;

        #endregion

        private void OnEnable()
        {
            gradientStatic = gradient;
        }

        private void Update()
        {
            if (Application.isPlaying)
            {
                QDraw.Matrix = transform.localToWorldMatrix;
                ControlCamera();

                deltaTime = Time.deltaTime;
                time += deltaTime * timeScale;
            }
            else
            {
                deltaTime = 0;
            }

            bounds = new Bounds(Vector2.zero, Vector2.one * areaSize * 1.25f);

            DrawBounds();
            DrawParticles();
            DrawVectorField();
        }

        private void DrawBounds()
        {
            var start = bounds.min;
            var size = bounds.size;
            QDraw.Quad(new Vector3(start.x, 0, start.y), new Vector3(size.x, 0, 0), new Vector3(0, 0, size.y), boundsColor);
        }

        private void DrawVectorField()
        {
            if (arrows.Length != fieldResolution * fieldResolution * 2)
            {
                if (arrows.IsCreated)
                {
                    arrows.Dispose();
                }

                arrows = new NativeArray<Line>(fieldResolution * fieldResolution * 2, Allocator.Persistent);
            }
            var vectorJob = new FieldArrowsJob
            {
                vectors = arrows,
                cellCount = fieldResolution,
                vectorLength = areaSize / fieldResolution,
                areaSize = areaSize,
                arrowWidth = arrowWidth,
                arrowHeadWidth = relativeArrowWidth * areaSize / fieldResolution,
                fieldOpacity = fieldOpacity,
                frequency = frequency,
                time = time,
            };

            var arrowCount = arrows.Length / 2;
            var handle = vectorJob.Schedule(arrowCount, 64);
            handle.Complete();

            unsafe
            {
                QDraw.Lines3D(arrows.Length, (Line*)arrows.GetUnsafeReadOnlyPtr());
            }
        }

        private void DrawParticles()
        {
            if (particles.Length != particleCount)
            {
                var particlesNew = new NativeArray<Vector3>(particleCount, Allocator.Persistent);
                var colorsNew = new NativeArray<Color32>(particleCount, Allocator.Persistent);
                GenerateParticles(particlesNew, colorsNew, areaSize, 0, particlesNew.Length);

                if (particles.IsCreated)
                {
                    NativeArray<Vector3>.Copy(particles, particlesNew, Mathf.Min(particles.Length, particlesNew.Length));
                    NativeArray<Color32>.Copy(colors, colorsNew, Mathf.Min(particles.Length, particlesNew.Length));
                    particles.Dispose();
                    colors.Dispose();
                }

                particles = particlesNew;
                colors = colorsNew;
            }

            var job = new FieldParticlesJob
            {
                particles = particles,
                colors = colors,
                particleOpacity = particleOpacity,
                areaSize = areaSize,
                bounds = bounds,
                deltaTime = deltaTime,
                frequency = frequency,
                particleSpeed = particleSpeed,
                time = time,
            };

            var handle = job.Schedule(particles.Length, 64);
            handle.Complete();

            unsafe
            {
                QDraw.Points(particles.Length, (Vector3*)particles.GetUnsafeReadOnlyPtr(), particleWidth, (Color32*)colors.GetUnsafeReadOnlyPtr());
            }

            if (boatsEnabled)
            {
                for (int i = 0; i < Mathf.Min(boatCount, particles.Length); i++)
                {
                    var position = particles[i];

                    var vector = GetVectorFieldValue(new Vector2(position.x, position.z), frequency, time);
                    var normal = new Vector3(vector.x, 0, vector.y);
                    var forward = transform.TransformDirection(Vector3.Cross(normal, Vector3.up));

                    QDraw.Icon3D("sailboat", position + .15f * Vector3.up, forward, Vector3.up, .5f, Color.white);
                }
            }
        }

        internal static Vector2 GetVectorFieldValue(Vector2 p, float frequency, float time)
        {
            const float DELTA = .01f;

            var value = GetScalarFieldValue(p, frequency, time);
            var dx = (GetScalarFieldValue(p + new Vector2(DELTA, 0), frequency, time) - value) / DELTA;
            var dy = (GetScalarFieldValue(p + new Vector2(0, DELTA), frequency, time) - value) / DELTA;
            return new Vector2(-dy, dx);
        }

        private static float GetScalarFieldValue(Vector2 p, float frequency, float time)
        {
            p *= frequency;
            return noise.GetValue(p.x, p.y - time);
        }

        internal static void GenerateParticles(NativeArray<Vector3> particles, NativeArray<Color32> colors, float areaSize, int offset, int count)
        {
            for (int i = 0; i < count; i++)
            {
                var rx = (float)random.NextDouble();
                var rz = (float)random.NextDouble();
                var x = areaSize * (rx - .5f);
                var z = areaSize * (rz - .5f);
                particles[offset + i] = new Vector3(x, 0, z);
                colors[offset + i] = gradientStatic.Evaluate(rz);
            }
        }

        private void OnDisable()
        {
            if (arrows.IsCreated)
            {
                arrows.Dispose();
            }

            if (particles.IsCreated)
            {
                particles.Dispose();
                colors.Dispose();
            }
        }
    }
}

Jobs Source

using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

namespace QuickDraw.Samples
{
    internal struct FieldParticlesJob : IJobParallelFor
    {
        public NativeArray<Vector3> particles;
        public NativeArray<Color32> colors;
        public float particleOpacity;
        public float particleSpeed;
        public float deltaTime;
        public float areaSize;
        public Bounds bounds;
        public float time;
        public float frequency;

        public void Execute(int index)
        {
            var p3 = particles[index];
            var p2 = new Vector2(p3.x, p3.z);

            var v2 = VectorFieldDemo.GetVectorFieldValue(p2, frequency, time);
            var v3 = new Vector3(v2.x, 0, v2.y);

            p3 += v3 * particleSpeed * deltaTime;
            p2 = new Vector2(p3.x, p3.z);
            if (bounds.Contains(p2))
            {
                particles[index] = p3;
            }
            else
            {
                VectorFieldDemo.GenerateParticles(particles, colors, areaSize, index, 1);
            }

            var color = colors[index];
            color.a = (byte)(particleOpacity * 255);
            colors[index] = color;
        }
    }

    internal struct FieldArrowsJob : IJobParallelFor
    {
        [NativeDisableParallelForRestriction, WriteOnly] public NativeArray<Line> vectors;
        public float areaSize;
        public int cellCount;
        public float time;
        public float frequency;
        public float fieldOpacity;
        public float vectorLength;
        public float arrowWidth;
        public float arrowHeadWidth;

        public void Execute(int index)
        {
            var ix = index / cellCount;
            var iz = index % cellCount;
            var x = areaSize * (ix / (cellCount - 1f) - .5f);
            var z = areaSize * (iz / (cellCount - 1f) - .5f);

            var p = new Vector2(x, z);
            var vector = VectorFieldDemo.GetVectorFieldValue(p, frequency, time);
            var magnitude = vector.magnitude;

            var color = VectorFieldDemo.gradientStatic.Evaluate(Mathf.InverseLerp(0, 1, magnitude));
            color.a = fieldOpacity;

            var halfVector = new Vector3(vector.x * .5f, 0, vector.y * .5f) / magnitude * vectorLength;

            var start = new Vector3(p.x, 0, p.y);
            var middle = start + halfVector;
            var end = middle + halfVector;

            vectors[2 * index + 0] = new Line(start, middle, arrowWidth, color);
            vectors[2 * index + 1] = new Line(middle, end, arrowHeadWidth, 0, color);
        }
    }
}

I’ve been spending more time on demos than I originally planned to for two reasons:

  1. The latest ones look much better than the first ones, so I don’t want to stop.
  2. It helps to solidify the API and find and fix minor inconveniences.

PS
Android Screenshots

PPS
The Megademo download links in the first post are up to date.

1 Like

Code

using System;
using System.Threading;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Jobs;
using UnityEngine;
using Random = System.Random;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class PointCloudDemo : MonoBehaviour
    {
        public GameObject house = null;
        [Range(0, 10_000)] public int raycastsPerFrame = 1_000;
        [Range(1, 1_000_000)] public int maxPointsCount = 500_000;
        [Range(0, 0.1f)] public float pointSize = .05f;
        public bool drawMesh;
        public Color meshColor = new Color(1, 1, 1, .01f);
        public Camera mainCamera = null;

        [Space]
        private NativeArray<Vector3> points;
        private NativeArray<Color32> colors;
        private int currentIndex;
        private int[] triangles;
        private Color32[] triangleColors;
        private Mesh mesh;

        private void Update()
        {
            if (points.Length != maxPointsCount)
            {
                DisposeArrays();
                InitializeArrays();
            }

            FireRaycasts();
            DrawPoints();
            if (drawMesh)
            {
                QDraw.Mesh(mesh, Vector3.zero, meshColor);
            }

            void FireRaycasts()
            {
                var origin = mainCamera.transform.position;

                // generate random raycasts
                var raycasts = new NativeArray<RaycastCommand>(raycastsPerFrame, Allocator.TempJob);
                var job = new GenerateRaycastsJob
                {
                    raycasts = raycasts,
                    origin = origin
                };
                var handle = job.Schedule(raycasts.Length, 64);
                handle.Complete();

                // fire raycasts
                var hits = new NativeArray<RaycastHit>(raycastsPerFrame, Allocator.TempJob);
                RaycastCommand.ScheduleBatch(raycasts, hits, 10).Complete();

                // convert hits to new points in the cloud
                for (var i = 0; i < raycastsPerFrame; i++)
                {
                    if (hits[i].collider == null)
                    {
                        continue;
                    }

                    points[currentIndex] = hits[i].point;
                    var firstTriangleVertexIndex = hits[i].triangleIndex * 3;
                    colors[currentIndex] = triangleColors[firstTriangleVertexIndex];
                    currentIndex = (currentIndex + 1) % maxPointsCount;
                }

                raycasts.Dispose();
                hits.Dispose();
            }

            unsafe void DrawPoints()
            {
                var positionsPtr = (Vector3*)points.GetUnsafeReadOnlyPtr();
                var colorsPtr = (Color32*)colors.GetUnsafeReadOnlyPtr();
                QDraw.Points3D(points.Length, positionsPtr, pointSize, colorsPtr);
            }
        }

        private void InitializeArrays()
        {
            points = new NativeArray<Vector3>(maxPointsCount, Allocator.Persistent);

            for (var i = 0; i < points.Length; i++)
            {
                points[i] = new Vector3(float.NaN, float.NaN, float.NaN);
            }

            mesh = house.GetComponentInChildren<MeshFilter>().sharedMesh;
            triangles = mesh.triangles;
            triangleColors = new Color32[triangles.Length];
            colors = new NativeArray<Color32>(maxPointsCount, Allocator.Persistent);

            var materials = house.GetComponentInChildren<MeshRenderer>().sharedMaterials;
            var submeshCount = mesh.subMeshCount;
            for (var submeshIndex = 0; submeshIndex < submeshCount; submeshIndex++)
            {
                var start = mesh.GetIndexStart(submeshIndex);
                var count = mesh.GetIndexCount(submeshIndex);
                for (var i = 0; i < count; i++)
                {
                    triangleColors[start + i] = materials[submeshIndex].color;
                }
            }
        }

        private void OnDisable()
        {
            DisposeArrays();
        }

        private void DisposeArrays()
        {
            if (points.IsCreated)
            {
                points.Dispose();
                colors.Dispose();
            }
        }

        private struct GenerateRaycastsJob : IJobParallelFor
        {
            [WriteOnly] public NativeArray<RaycastCommand> raycasts;
            public Vector3 origin;
            [ThreadStatic] private static Random random;

            public void Execute(int index)
            {
                raycasts[index] = new RaycastCommand(origin, OnUnitSphere());
            }

            private static Vector3 OnUnitSphere()
            {
                if (random == null)
                {
                    random = new Random(Thread.CurrentThread.ManagedThreadId);
                }

                var u = (float)random.NextDouble();
                var v = (float)random.NextDouble();
                var theta = 2 * Mathf.PI * u;
                var phi = Mathf.Acos(2 * v - 1);
                var sinPhi = Mathf.Sin(phi);
                var x = sinPhi * Mathf.Cos(theta);
                var y = sinPhi * Mathf.Sin(theta);
                var z = Mathf.Cos(phi);
                return new Vector3(x, y, z);
            }
        }
    }
}

Screenshots

Code

using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class DigitalWindDemo : DemoBase
    {
        [Header("Shape Resolution")]
        [Range(20, 1000)] public int countA = 200;
        [Range(6, 300)] public int countB = 60;

        private struct Particle
        {
            public Vector3 position;
            public float size;
            public Color32 color;
        }

        [WriteOnly] private NativeArray<Particle> particles;

        private void Update()
        {
            QDraw.Matrix = transform.localToWorldMatrix;

            if (Application.isPlaying)
            {
                ControlCamera();
            }

            if (particles.Length != countA * countB)
            {
                if (particles.IsCreated)
                {
                    particles.Dispose();
                }

                particles = new NativeArray<Particle>(countA * countB, Allocator.Persistent);
            }


            DrawShape();
        }

        private void DrawShape()
        {
            var job = new DigitalWindJob
            {
                particles = particles,
                count = countA,
                count2 = countB,
                minPerlinLoopRadius = .1f,
                maxPerlinLoopRadius = 2f,
                speedA = .1f,
                speedB = .3f,
                time = Time.time,
            };
            var handle = job.Schedule(particles.Length, countB);
            handle.Complete();

            foreach (var particle in particles)
            {
                QDraw.Point(particle.position, particle.size, particle.color);
            }

            for (int i = 1; i < countA; i++)
            {
                for (int j = 1; j < countB; j++)
                {
                    var particleA = particles[i * countB + j];
                    var particleB = particles[(i - 1) * countB + j];
                    var particleC = particles[i * countB + (j - 1)];

                    QDraw.Line(particleA.position, particleB.position, particleA.color, particleB.color);
                    QDraw.Line(particleC.position, particleB.position, particleC.color, particleB.color);
                }
            }
        }

        private void OnDisable()
        {
            if (particles.IsCreated)
            {
                particles.Dispose();
            }
        }

        private struct DigitalWindJob: IJobParallelFor
        {
            public NativeArray<Particle> particles;
            public int count, count2;
            public float minPerlinLoopRadius, maxPerlinLoopRadius;
            public float speedA, speedB;
            public float time;

            public void Execute(int index)
            {
                var a = index / count2 / (count - 1f);
                var b = index % count2 / (count2 - 1f);

                var angleA = a * Mathf.PI * 2 - time * .1f;
                var angleB = b * Mathf.PI * 2;

                var zRotation = Quaternion.Euler(0, 0, angleA * Mathf.Rad2Deg);
                var yRotation = Quaternion.Euler(0, angleB * Mathf.Rad2Deg, 0);

                var perlinLoopRadius = minPerlinLoopRadius + Mathf.PingPong(b * 2, 1) * (maxPerlinLoopRadius - minPerlinLoopRadius);
                var x = Mathf.Cos(angleA) * perlinLoopRadius;
                var y = Mathf.Sin(angleA) * perlinLoopRadius;
                var perlin = Mathf.PerlinNoise(x + time * speedA, y + time * speedB);

                var position = zRotation * (yRotation * new Vector3(.5f + perlin * .5f, 0, 0) + new Vector3(.5f + perlin * 2, 0, 0));
                var center = zRotation * new Vector3(.5f + perlin * 2, 0, 0);

                var size = 1 + Mathf.PingPong(1 + b * 2, 1) * 5;

                var color = Color.HSVToRGB(Mathf.Repeat(time * .1f + a + Mathf.PingPong(b * 2, 1) * .15f, 1), 1, 1);
                color *= .3f + .7f * Vector3.Dot(Vector3.right, (position - center).normalized);

                particles[index] = new Particle
                {
                    position = position,
                    size = size,
                    color = color
                };
            }
        }
    }
}

Code

using System.Linq;
using UnityEngine;
using WasapiAudio;
using WasapiAudio.Core;

namespace QuickDraw.Samples
{
    [ExecuteAlways]
    internal class DiscoSpiralDemo : MonoBehaviour
    {
        [Header("Spiral")]
        public int rows = 200;
        public int columns = 200;
        public float quadWidth = .05f;
        public float quadHeight = .05f;
        public bool alternativeStyle = false;

        [Header("Rays")]
        public Color rayColor = new Color(.06f, .05f, .08f, .3f);

        private WasapiAudio.WasapiAudio wasapiAudio;
        private float[] spectrumData;
        private float averageLevel;
        private float velocity;
        private readonly Vector3[] rays = new Vector3[100];

        public void OnEnable()
        {
            for (var i = 0; i < rays.Length; i++)
            {
                rays[i] = Random.onUnitSphere;
            }

            spectrumData = new float[columns];
            wasapiAudio = new WasapiAudio.WasapiAudio(WasapiCaptureType.Loopback, columns, ScalingStrategy.Linear, 100, 20000, null, data => { spectrumData = data; });
            wasapiAudio.StartListen();
        }

        public void OnDisable()
        {
            wasapiAudio.StopListen();
        }

        private void Update()
        {
            QDraw.Matrix = transform.localToWorldMatrix;

            for (var i = 0; i < spectrumData.Length; i++)
            {
                spectrumData[i] *= 4;
            }

            averageLevel = Mathf.SmoothDamp(averageLevel, spectrumData.Sum() / spectrumData.Length, ref velocity, .1f);

            DrawAll();
        }

        private void DrawAll()
        {
            DrawSpectrum();
            DrawDiscoBalls();
        }

        private void DrawSpectrum()
        {
            for (var column = 0; column < columns; column++)
            {
                var t = column / (columns - 1f);
                using (QDraw.Push())
                {
                    QDraw.Matrix *= Matrix4x4.TRS(Vector3.zero, Quaternion.Euler(0, 0, t * 1444 - averageLevel * 100 + Time.time * 10), Vector3.one * (.6f + averageLevel * 4));
                    DrawColumn(column, new Vector3(0, t * 4, 0));
                }
            }
        }


        private void DrawColumn(int index, Vector3 start)
        {
            var top = (int)(spectrumData[index] * rows);
            for (var row = 0; row < top; row++)
            {
                var t = row / (rows - 1f);
                float hue;
                if (alternativeStyle)
                {
                    hue = Mathf.Repeat(1 - spectrumData[index] - t + Time.time * .05f - (index + Time.time) * .005f, 1);
                }
                else
                {
                    hue = Mathf.Repeat(1 - t * 4 + Time.time * .05f - (index + Time.time) * .005f, 1);
                }

                var color = Color.HSVToRGB(hue, 1, 1);

                var startPosition = start + row * quadHeight * Vector3.up;
                var width = quadWidth * Mathf.Lerp(.5f, 1.5f, t * 10);
                var height = quadHeight * .75f * Mathf.Lerp(.5f, 1.5f, t * 10);
                QDraw.SolidQuad(startPosition, new Vector3(width, 0, 0), new Vector3(0, height, 0), color);
            }
        }

        private void DrawDiscoBalls()
        {
            using (QDraw.Push())
            {
                var rotation = Quaternion.Euler(0, Time.time * 10, 0);

                QDraw.Matrix = Matrix4x4.TRS(new Vector3(-3f, 3, 0), rotation, Vector3.one);
                DrawRays();

                QDraw.Matrix = Matrix4x4.TRS(new Vector3(3f, 3, 0), rotation, Vector3.one);
                DrawRays();
            }

            void DrawRays()
            {
                for (var i = 0; i < rays.Length; i++)
                {
                    var start = rays[i] * .5f;
                    var end = rays[i] * 10;
                    QDraw.Line3D(start, end, 0, .1f, rayColor, Color.clear);
                }
            }
        }
    }
}

This is mostly an experiment. The script captures the current audio using UnityWasapiAudio and draws its spectrum in real time.

I’m wondering how would one make visuals like that using regular Unity tools. Particles? Dynamic mesh?

This looks soooo cool!!! ETA on Asset Store availability? :wink: