Learn NinjaScript: OnRender and SharpDX Drawing
Everything drawn so far has used Draw.* — lines, dots, triangles, text boxes that NT manages for you. They work, but every call churns the drawing-object collection and scales badly on dense charts or long live sessions. For polished, performant output — fills, gradients, crisp text, thousands of bars — drop down to SharpDX and override OnRender.
This post covers the OnRender contract, the lifecycle of DX resources, the three gates every OnRender must start with, bar-index clamping, coordinate conversion, and the pitfalls that cause crashes on live charts. The anchor extends MySma with a SharpDX value label that always sticks to the latest SMA value.
🎨 When OnRender Wins
Use Draw.* for simple per-bar markers — triangles, arrows, dots, crosses. They’re one-liners, NT handles state, and 20 of them per chart costs nothing.
Reach for OnRender when any of these apply:
- Filled shapes. Band fills between two lines, trapezoids, paths —
Draw.Regionexists but is heavy and awkward. - Text with tight control. A crisp label with font, size, weight, and anti-aliasing —
Draw.Textsupports some of this but renders at a different resolution tier. - Per-frame animation. Anything that should redraw when the chart scrolls, not when a bar closes.
- High-density output. Thousands of small marks where
Draw.*object overhead starts to matter. - Custom shapes. Anything that doesn’t map to a built-in
Drawprimitive.
A rough rule: if the chart has more than a few hundred visible bars and you’d need to call Draw.* for each of them, switch to OnRender. If it’s one label per signal event, stick with Draw.Text.
🛡️ Render Gating
Every OnRender starts with the same four checks. Skip them and you get null-deref crashes during chart teardown, wasted frames during hit-testing, and CPU burn on panels the user can’t see:
protected override void OnRender(ChartControl chartControl, ChartScale chartScale)
{
base.OnRender(chartControl, chartScale);
if (RenderTarget == null) return;
if (ChartBars == null) return;
if (!IsVisible) return;
if (IsInHitTest) return;
// Short-circuit if the user has disabled every drawn feature.
if (!Highlighting && !ShowSignals) return;
// ... drawing
}
Each gate has a reason:
RenderTarget == null— the DX target hasn’t been created yet (or has been torn down). Any DX call will throw.ChartBars == null— no bars have been loaded onto the chart. Your bar loop would iterate over nothing but bar-index math could trip.!IsVisible— the indicator is toggled off in the panel. Don’t burn frames drawing invisible output.IsInHitTest— NT is running a hit-test to figure out which object the mouse is over. You don’t need to re-draw; returning early saves thousands of cycles per mouse move.- Feature short-circuit. If every user-toggled visual is off, bail out before any DX work.
♻️ DX Resources and OnRenderTargetChanged
SharpDX brushes, text formats, and geometry objects are native resources — they live outside the .NET GC and must be disposed explicitly. The rule: allocate them in OnRenderTargetChanged, dispose them there and in State.Terminated, and never create one inside OnRender. Per-frame allocation leaks native memory and tanks frame rate.
private SharpDX.Direct2D1.Brush dxUp;
private SharpDX.Direct2D1.Brush dxDn;
private SharpDX.Direct2D1.Brush dxText;
private SharpDX.DirectWrite.TextFormat textFormat;
public override void OnRenderTargetChanged()
{
try
{
ReleaseRenderResources();
if (RenderTarget == null) return;
dxUp = UpBrush.ToDxBrush(RenderTarget);
dxDn = DnBrush.ToDxBrush(RenderTarget);
dxText = Brushes.White.ToDxBrush(RenderTarget);
textFormat = new SharpDX.DirectWrite.TextFormat(
Core.Globals.DirectWriteFactory,
"Segoe UI", SharpDX.DirectWrite.FontWeight.SemiBold,
SharpDX.DirectWrite.FontStyle.Normal, TextSize);
}
catch { /* suppress during chart teardown */ }
}
private void ReleaseRenderResources()
{
if (dxUp != null) { dxUp.Dispose(); dxUp = null; }
if (dxDn != null) { dxDn.Dispose(); dxDn = null; }
if (dxText != null) { dxText.Dispose(); dxText = null; }
if (textFormat != null) { textFormat.Dispose(); textFormat = null; }
}
OnRenderTargetChanged fires when the render target is created, resized, or destroyed — which also happens when the user drags the chart to a different monitor. Always ReleaseRenderResources first, then check RenderTarget != null before re-creating. The try/catch around the body swallows the benign exceptions that can fire during chart teardown.
brush.ToDxBrush(RenderTarget) is the NT extension method that converts a WPF Brush to a SharpDX brush. Prefer it over a hand-written MakeDXBrush helper when the WPF brush is a plain SolidColorBrush.
🛠️ The Anchor Example — MySma With a SharpDX Value Label
Extending MySma with a SharpDX label that pins the current SMA value to the right edge of the chart. This demonstrates the full pattern: gating, bar-index clamping, coordinate conversion, text rendering, and cleanup:
protected override void OnRender(ChartControl chartControl, ChartScale chartScale)
{
base.OnRender(chartControl, chartScale);
if (RenderTarget == null) return;
if (ChartBars == null) return;
if (!IsVisible) return;
if (IsInHitTest) return;
if (!ShowValueLabel) return;
// Clamp bar-index range — never overshoot CurrentBar.
int fromIdx = Math.Max(ChartBars.FromIndex, 0);
int toIdx = Math.Min(ChartBars.ToIndex, CurrentBar);
if (toIdx <= fromIdx) return;
double sma = Values[0].GetValueAt(toIdx);
float x = chartControl.GetXByBarIndex(ChartBars, toIdx) + 8f;
float y = (float)chartScale.GetYByValue(sma) - (TextSize / 2f + 2f);
string text = sma.ToString(Instrument.MasterInstrument.TickSize < 1 ? "F2" : "F0");
using (var layout = new SharpDX.DirectWrite.TextLayout(
Core.Globals.DirectWriteFactory, text, textFormat, 120f, TextSize * 1.6f))
{
RenderTarget.DrawTextLayout(new SharpDX.Vector2(x, y), layout, dxText);
}
}
The fromIdx / toIdx clamp is critical. At the right edge during a Historical→Realtime transition, ChartBars.ToIndex can momentarily exceed CurrentBar. Calling Values[0].GetValueAt(toIdx) on a not-yet-processed bar throws. The Math.Min keeps the loop inside valid territory.
GetXByBarIndex maps an absolute bar index to a pixel X coordinate; GetYByValue does the same for prices on the Y axis. Both take the current chart scale into account, so pinned labels track correctly when the user scrolls, zooms, or resizes.
The TextLayout is created per-frame — that’s acceptable because text content changes per frame (the last-bar value moves). Wrap it in using so the native resource disposes even if the draw throws. For static text (a header, a legend), build the layout once in OnRenderTargetChanged and reuse it.
📝 Pitfalls Checklist
- Missing
RenderTargetnull check. During chart teardownOnRendercan fire one last time withRenderTarget == null. Every DX call will null-ref. The null check is mandatory. - Creating brushes in
OnRender. Every frame allocates a native DX resource that the GC can’t clean up. Live charts with 60fps redraw turn this into a steady memory leak in minutes. Allocate inOnRenderTargetChanged, reuse inOnRender. - Using
series[barsAgo]insideOnRender. The bars-ago indexer works inOnBarUpdatebut throws inOnRenderwhen bars are off the chart. Always useseries.GetValueAt(absoluteIndex)withIsValidDataPointAt(absoluteIndex)for validity. - Not clamping
ToIndex. At the right edge,ChartBars.ToIndexcan overshootCurrentBar. Clamp withMath.Min(ChartBars.ToIndex, CurrentBar)or expect the “accessing an index with a value that is invalid” exception. - Forgetting
usingonTextLayoutandPathGeometry. Both are native. An exception mid-draw leaks the resource. Wrap inusingortry/finallywith explicitDispose. - Not exposing text size and color as properties. Users run charts at many resolutions — a hard-coded font size that looks right at 1080p is unreadable at 4K. Every SharpDX text element needs
TextSize(int,[Range(6, 48)]) and aBrushcolor property with aSerializepair, both in theDisplaygroup. - Not disposing in
State.Terminated. Just having aDisposepath inOnRenderTargetChangedisn’t enough — NT may callState.Terminatedwithout triggering a finalOnRenderTargetChanged. CallReleaseRenderResources()from theTerminatedbranch ofOnStateChangetoo.








