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.