Charts 2D
A flat, paper-like chart drawn entirely in code - no assets, no chart control, every line a pixel-width stroke in a shape batch. Function plots handle their own awkward cases: ln(x) starts where its domain does, tan(x) is cut into branches at its asymptotes instead of being joined by a false vertical line, and everything is clipped to the chart's ranges. On top of that sit a parametric loop, scatter markers, a shaded region under a curve, a trajectory that records a thrown ball while it flies, and a curve whose function is swapped every frame. Pan and zoom and the chart re-targets its ranges to whatever the camera sees, rebuilding axes, ticks, labels and curves for the new view - the Desmos trick.
The Program.cs file shows how to:
- Plotting y = f(x) with clipping, NaN handling and asymptote splitting
- Parametric curves closed back on themselves
- Scatter markers drawn as pixel-sized glyphs
- Shading the region under a curve
- Recording a moving body with a growing trajectory
- Animating a curve by swapping its function in place
- A view-driven chart that follows an orthographic camera
- Grouped ChartOptions and per-series colour
Note
This example references Stride.CommunityToolkit.Charts, which is not on NuGet yet. Run it from a clone of the
repository, where the package is a project reference; a copy of the project on its own will not build.

View on GitHub.
using Stride.CommunityToolkit.Charts;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.Rendering.Compositing;
using Stride.CommunityToolkit.Scripts;
using Stride.CommunityToolkit.Scripts.Utilities;
using Stride.CommunityToolkit.Shapes;
using Stride.CommunityToolkit.Windows;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;
using Stride.Graphics;
using Stride.Input;
using System.Globalization;
// A flat, paper-like chart under an orthographic camera - every feature the chart library has in 2D:
// function plots with their asymptotes clipped, a parametric loop, scatter markers, a shaded region, a
// trajectory recorded from a moving body, an animated curve, a legend, titles and a mouse readout.
//
// The one thing here that a 3D chart cannot do is FollowCamera: the chart re-targets its ranges to
// whatever the camera sees, so panning and zooming rebuild the axes, ticks, labels and curves for the
// new view. That is an orthographic idea, and it is what makes this feel like Desmos rather than a
// figure printed on a page.
//
// Controls: drag to pan, wheel to zoom, G grid, L legend, T removes or restores tan, Space throws the
// ball, A pauses the animated curve. The keys share a screen block with the camera help - F2 collapses
// it, F3 moves it, F4 hides it.
// Without this a scaled-up 4K desktop hands the game a scaled, blurred window. A no-op off Windows.
WindowsDpiManager.EnablePerMonitorV2();
// "--zoom 61" starts zoomed out to that view height - handy for seeing how the view-driven chart adapts
// its tick step, sampling density and line widths
var zoomArg = 0f;
var zoomIndex = Array.FindIndex(args, a => a.Equals("--zoom", StringComparison.OrdinalIgnoreCase));
if (zoomIndex >= 0 && zoomIndex + 1 < args.Length) _ = float.TryParse(args[zoomIndex + 1], NumberStyles.Float, CultureInfo.InvariantCulture, out zoomArg);
using var game = new Game();
Chart? chart = null;
ChartCurve? tangent = null;
ChartCurve? wave = null;
ChartTrajectory? trail = null;
CameraComponent? camera = null;
ShapeBatch? batch = null;
// The thrown ball: launched from the lower left, integrated by hand each frame - the point is watching
// the trajectory series record a moving body, not the integrator
var launchPosition = new Vector2(-4.5f, -3.5f);
var launchVelocity = new Vector2(4f, 7f);
const float Gravity = 9.81f;
var ballPosition = Vector2.Zero;
var ballVelocity = Vector2.Zero;
var ballFlying = false;
// The animated curve: sin(kx) with k sweeping up and down, re-plotted in place every frame
var animate = true;
var waveTime = 0f;
var waveFrequency = 1f;
const float WaveAmplitude = 1.5f;
game.Run(start: Start, update: Update);
void Start(Scene rootScene)
{
game.Window.AllowUserResizing = true;
game.Window.Title = "Charts 2D";
// What SetupBase2DScene does, minus the physics ground a chart does not need, plus MSAA (thin lines
// flicker without it) and a paper-like background
game.Add2DGraphicsCompositor(clearColor: new Color(250, 250, 250), msaa: MultisampleCount.X4).AddUIStage();
game.Add2DCamera();
var cameraEntity = game.Add2DCameraController();
var controller = cameraEntity.Get<Basic2DCameraController>()!;
// Left-drag pans like a canvas app, and the view-driven chart invites zooming far out
controller.MouseDragButton = MouseButton.Left;
controller.MaxOrthographicSize = 500f;
if (zoomArg > 0f && cameraEntity.Get<CameraComponent>() is { } zoomCamera)
{
zoomCamera.OrthographicSize = zoomArg;
}
// Light2D is the flat preset: dark axes, a major and minor grid, no glow, and labels that keep their
// pixel size while zooming. Everything below is a change to one group of it.
var options = ChartOptions.Light2D();
options.Range.XMin = -5f;
options.Range.XMax = 5f;
options.Range.YMin = -4f;
options.Range.YMax = 4f;
options.Title.Text = "Charts 2D";
options.Axes.XTitle = "x";
options.Axes.YTitle = "y";
chart = new Chart(game, options);
chart.Root.Scene = rootScene;
// Curves added without a colour take the next one from the palette
chart.Plot(x => 2f * MathF.Sin(x), name: "sin");
chart.Plot(x => 0.15f * x * x - 3f, name: "parabola");
// The awkward ones: ln(x) is NaN left of zero, so the curve simply starts at the y axis; tan(x)
// shoots past the top and bottom and jumps across each asymptote, and is clipped to the edges and
// cut into branches rather than joined by a false vertical line
chart.Plot(MathF.Log, name: "ln");
tangent = chart.Plot(MathF.Tan, name: "tan", samples: 600);
// A parametric curve closed back on itself
chart.PlotParametric(
t => new Vector3(1.5f * MathF.Cos(t), 1.5f * MathF.Sin(t), 0f), 0f, MathUtil.TwoPi,
color: options.Series.Palette[4],
name: "circle",
samples: 96,
closed: true);
// The analytic flight path, thin and faint, so the recorded trail can be seen landing exactly on the
// curve the equations predict: y = y0 + (vy/vx)(x - x0) - g (x - x0)^2 / (2 vx^2)
chart.Plot(
x => launchPosition.Y + launchVelocity.Y / launchVelocity.X * (x - launchPosition.X)
- Gravity * (x - launchPosition.X) * (x - launchPosition.X) / (2f * launchVelocity.X * launchVelocity.X),
color: new Color(180, 180, 180),
name: "ballistic",
style: new ChartSeriesStyle { Width = options.Series.CurveWidth * 0.5f });
// The live trail the flying ball leaves behind; one point is appended per frame in Update
trail = chart.AddTrajectory(capacity: 900, name: "throw");
// Scatter: noisy measurements around the sin curve, drawn as pixel-sized x glyphs each frame - the
// classic "data points versus fitted curve" picture. The jitter is a deterministic hash of x, so the
// picture is identical every run without involving Random.
var samples = new List<Vector3>();
for (var x = -4.5f; x <= 4.5f; x += 0.45f)
{
var hash = MathF.Sin(x * 12.9898f) * 43758.5453f;
var jitter = (hash - MathF.Floor(hash) - 0.5f) * 0.7f;
samples.Add(new Vector3(x, 2f * MathF.Sin(x) + jitter, 0f));
}
chart.AddMarkers(samples, color: new Color(96, 66, 166), name: "samples");
// The integral picture: one arch of the sine shaded down to the x axis, re-sampled and re-clipped
// with the curves whenever the view changes
chart.AddArea(x => 2f * MathF.Sin(x), from: 0f, to: MathF.PI, color: options.Series.Palette[0], name: "integral");
// The animated curve. An explicit colour keeps the palette rotation of the other series unchanged.
wave = chart.Plot(x => WaveAmplitude * MathF.Sin(waveFrequency * x), color: new Color(0, 158, 150), name: "wave");
// The ball is drawn each frame as a disc in a shape batch the chart shares: one batch draws the
// chart's strokes and the ball, and submitting the ball after Update puts it over the curves
batch = game.AddShapeBatch(depthTest: true);
// A readout that follows the mouse over the chart plane
chart.Options.Cursor.Visible = true;
// The view-driven part: the chart re-targets its ranges to whatever the camera sees, so the grid
// always fills the window and the tick step adapts to the zoom
chart.Options.Range.FollowCamera = true;
ThrowBall();
var overlay = DebugOverlay.GetOrCreate(game);
// The default box is Stride's 49% black, tuned for dark scenes; on paper white it needs to be darker
overlay.BackgroundColor = new Color(0, 0, 0, 200);
overlay.FontSize = 16;
overlay.LineSpacing = 1;
overlay.AddSection("Chart", () =>
[
new("CHART"),
new($"Press G to toggle the grid ({(chart.Options.Grid.Visible ? "on" : "off")})", Color.Yellow),
new($"Press T to {(tangent is null ? "restore" : "remove")} the tan curve", Color.Yellow),
new($"Press L to toggle the legend ({(chart.Options.Legend.Visible ? "on" : "off")})", Color.Yellow),
new($"Press Space to throw the ball (trail: {trail.Count}/{trail.Capacity} points)", Color.Yellow),
new($"Press A to {(animate ? "pause" : "resume")} the wave (k = {waveFrequency:0.00})", Color.Yellow),
new($"{chart.Series.Count} series: {string.Join(", ", chart.Series.Select(s => s.Name))}"),
]);
}
void Update(Scene scene, GameTime time)
{
if (chart is null) return;
if (game.Input.IsKeyPressed(Keys.G))
{
chart.Options.Grid.Visible = !chart.Options.Grid.Visible;
}
if (game.Input.IsKeyPressed(Keys.L))
{
chart.Options.Legend.Visible = !chart.Options.Legend.Visible;
}
// Remove frees the ribbon's GPU buffers; plotting again builds new ones
if (game.Input.IsKeyPressed(Keys.T))
{
if (tangent is null)
{
tangent = chart.Plot(MathF.Tan, name: "tan", samples: 600);
}
else
{
chart.Remove(tangent);
tangent = null;
}
}
if (game.Input.IsKeyPressed(Keys.Space))
{
ThrowBall();
}
if (game.Input.IsKeyPressed(Keys.A))
{
animate = !animate;
}
// Swapping the function rebuilds one mesh in place - same entity, same colour, same legend row - so
// it is cheap enough to do every frame
if (animate && wave is not null)
{
waveTime += (float)time.Elapsed.TotalSeconds;
waveFrequency = 1.75f + 1.25f * MathF.Sin(waveTime * 0.9f);
wave.SetFunction(x => WaveAmplitude * MathF.Sin(waveFrequency * x));
}
// The chart's frame: applies whatever changed in its options since last time (the G and L toggles
// above, the range the follower derives from the camera) and moves the cursor readout. The camera is
// explicit because a scene can hold several, and only you know which one is looking at this chart.
camera ??= scene.Entities.Select(e => e.Get<CameraComponent>()).FirstOrDefault(c => c != null);
if (camera is not null && batch is not null)
{
chart.Update(camera, batch);
batch.DrawDisc(new Vector3(ballPosition.X, ballPosition.Y, 0.05f), Vector3.UnitZ, 0.12f, new Color(40, 40, 40));
}
if (!ballFlying || trail is null) return;
// Semi-implicit Euler; capped so a stall (a dragged window) cannot teleport the ball
var dt = MathF.Min((float)time.Elapsed.TotalSeconds, 0.1f);
ballVelocity.Y -= Gravity * dt;
ballPosition += ballVelocity * dt;
// The trajectory clips to the chart's ranges by itself; the trail simply ends at the edge
trail.Add(new Vector3(ballPosition.X, ballPosition.Y, 0f));
if (ballPosition.Y < chart.Options.Range.YMin - 1f || ballPosition.X > chart.Options.Range.XMax + 1f)
{
ballFlying = false;
}
}
void ThrowBall()
{
trail?.Clear();
ballPosition = launchPosition;
ballVelocity = launchVelocity;
ballFlying = true;
}