2 years ago
#72444
cocoz1
How to efficiently draw to plain win32 windows using Direct2D and GDI
I've been working on a GUI toolkit for my future programming needs. It's basically reinventing the wheel and implementing many controls found in Windows' common controls, QT and other frameworks. It's going to be used by me mainly.
It's main design guidelines are:
- implemented in plain C (not C++) and Win32 (GDI + Direct2D) (no other external dependencies)
- easy to look at (even for a long time)
- customization similar to QT's css-based stylesheets
- easy to render (not much complex geometry)
- really good performance (no performance issues, even in large GUI projects)
It's been going quite well for now and I have managed to implement quite a few important and trivial controls. Right now, I am building my slider control that can be a rotary slider (like QDial), or a horizontal or vertical bar slider. While there are no obvious bugs that I have noticed during my testing, I am questioning the way I am rendering the control (using Direct2D and GDI).
Below you can find the commented draw code and the result it produces. I know it's not perfect by any means but it works flawlessly for me. Please do not judge my coding style for this question is really not on that.
static int __Slider_Internal_DCDBufferDraw(Slider *sSlider) {
if (!sSlider->_Base.sDraw)
return ERROR_OK;
/* start timer */
LARGE_INTEGER t1, t2;
QueryPerformanceCounter(&t1);
/* appearance depends on enabled state of the control */
_Bool blIsEnabled = IsWindowEnabled(sSlider->_Base.hwWindow /* control's HWND instance */);
D2D1_ELLIPSE sInnerCircle = {
.point = { __SlR_C /* center of circle, essentially width / 2 */, __SlR_C + __ClH(sSlider) / 6.0f /* center + some offset */ },
.radiusX = __SlR_IR + 0.5f, /* IR = inner radius */
.radiusY = ___SlR_IR + 0.5f
};
D2D1_ELLIPSE sOuterCircle = {
.point = { __SlR_C, __SlR_C },
.radiusX = __SlR_OR + 0.5f, /* OR = outer radius */
.radiusY = __SlR_OR + 0.5f
};
D2D1_BEGIN:
/*
Global struct "gl_sD2D1Renderer" contains a ID2D1Factory, a ID2D1DCRenderTarget (.sDCTarget), and a ID2D1SolidColorBrush (.sDCSCBrush).
Every control uses this DC to draw Direct2D content. Before anything is drawn, the DC is bound. Right now, I draw everything to an control instance-specific
HDC "sSlider->_Base.sDraw->hMemDC" in this function. In my actual WM_PAINT handler, I just BitBlt the memory bitmap. This
(1) removes flickering,
(2) improves draw speed for normal WM_PAINT commands, for example, when the client area of the window is uncovered/moved/etc.
In these cases, I just use the most recent representation without redrawing everything because the control
only changes its appearance in reaction to user input.
The brush is used to basically draw all the color information. It just gets its color changed every time it's needed.
The reason I am using a DC render target is because
(1) of its reusability (can be used for drawing all controls, without having to create separate render targets for each control instance)
(2) GDI compatibility (see "__Slider_Internal_DrawNumbersAndText"'s comment below to learn why I need it)
*/
ID2D1DCRenderTarget_BindDC(gl_sD2D1Renderer.sDCTarget, sSlider->_Base.sDraw->hMemDC, &sSlider->_Base.sClientRect);
ID2D1DCRenderTarget_BeginDraw(gl_sD2D1Renderer.sDCTarget);
ID2D1DCRenderTarget_Clear(gl_sD2D1Renderer.sDCTarget, &colBkgnd);
/* rotate the smaller circle by the current slider position (min ... max) */
D2D1_MATRIX_3X2_F sMatrix;
D2D1MakeRotateMatrix(sSlider->flPos, (D2D1_POINT_2F){ __SlR_C, __SlR_C}, &sMatrix);
ID2D1DCRenderTarget_SetTransform(gl_sD2D1Renderer.sDCTarget, &sMatrix);
/* draw the outer circle */
ID2D1SolidColorBrush_SetColor(gl_sD2D1Renderer.sDCSCBrush, blIsEnabled ? &colBtnSurf : &colBtnDisSurf);
ID2D1DCRenderTarget_FillEllipse(gl_sD2D1Renderer.sDCTarget, &sOuterCircle, (ID2D1Brush *)gl_sD2D1Renderer.sDCSCBrush);
ID2D1SolidColorBrush_SetColor(gl_sD2D1Renderer.sDCSCBrush, &colOutline);
ID2D1DCRenderTarget_DrawEllipse(gl_sD2D1Renderer.sDCTarget, &sOuterCircle, (ID2D1Brush *)gl_sD2D1Renderer.sDCSCBrush, 1.0f, NULL);
/* draw the inner circle */
ID2D1SolidColorBrush_SetColor(gl_sD2D1Renderer.sDCSCBrush, blIsEnabled ? (sSlider->_Base.wState & STATE_CAPTURE || sSlider->_Base.wState & STATE_MINSIDE ? &colBtnSelSurf : &colMark) : &colMarkDis);
ID2D1DCRenderTarget_FillEllipse(gl_sD2D1Renderer.sDCTarget, &sInnerCircle, (ID2D1Brush *)gl_sD2D1Renderer.sDCSCBrush);
ID2D1SolidColorBrush_SetColor(gl_sD2D1Renderer.sDCSCBrush, &colOutline);
ID2D1DCRenderTarget_DrawEllipse(gl_sD2D1Renderer.sDCTarget, &sInnerCircle, (ID2D1Brush *)gl_sD2D1Renderer.sDCSCBrush, 1.0f, NULL);
/* reset the transform */
ID2D1DCRenderTarget_SetTransform(gl_sD2D1Renderer.sDCTarget, &gl_sD2D1Renderer.sIdentityMatrix);
/* draw ticks using Direct2D */
__Slider_Internal_DrawTicks(sSlider, 0); /* draw small ticks */
__Slider_Internal_DrawTicks(sSlider, 1); /* draw big ticks */
/* Call EndDraw, check for render target errors, drop the render target if necessary, recreate it and "goto D2D1_BEGIN;" */
ID2D1DCRenderTarget_SafeEndDraw(gl_sD2D1Renderer.sDCTarget, NULL, NULL);
/*
Draw text using plain GDI (no DirectWrite because there is no functioning C-API.)
I have to do this here because I need to finish rendering the D2D content first. If I render GDI content in between Direct2D calls,
it would just be overdrawn because drawing is actually done in "EndDraw", rather than in "DrawEllipse", "Clear", etc. These calls just
build a batch while "Ellipse" or "TextExtOut" do immediately draw.
*/
__Slider_Internal_DrawNumbersAndText(sSlider);
/* end timer */
QueryPerformanceCounter(&t2);
LARGE_INTEGER freq;
QueryPerformanceFrequency(&freq);
double elapsed = (double)(t2.QuadPart - t1.QuadPart) / (freq.QuadPart / 1000.0);
printf("Draw call of \"%s\" took: %g ms\n", sSlider->_Base.strID, elapsed);
return ERROR_OK; /* 0 */
}
static int __Slider_Internal_DrawTicks(Slider *sSlider, int dwType) {
/* BTC = big tick count */
/* STC = small tick count */
/* check if ticks can be drawn, return if, for instance, not all data is present or tick drawing is disabled */
if (!(dwType ? sSlider->wBTC : sSlider->wSTC) || !(sSlider->wType & (dwType ? SLO_BIGTICKS : SLO_SMALLTICKS)))
return __ERROR_OK;
/* tick color */
ID2D1SolidColorBrush_SetColor(gl_sD2D1Renderer.sDCSCBrush, &colOutline); /* RGB(0, 0, 0) */
float flCurrPos = sSlider->sPosRange.flMin; /* start at the minimum possible angle for this slider */
/* calculate the step, i.e. angle to advance based on requested tick count and valid position (angle) range */
float flStep = (sSlider->sPosRange.flMax - sSlider->sPosRange.flMin) / (dwType ? sSlider->wBTC : sSlider->wSTC);
D2D1_MATRIX_3X2_F sMatrix; /* rotation matrix */
D2D1_POINT_2F sP1, sP2; /* start and end point of the line representing a tick */
D2D1_POINT_2F sCenter = {
__SlR_C + 0.5f,
__SlR_C + 0.5f
};
/* calculate tick dimensions given the type (= small or large) */
__Slider_getTickDimensions(sSlider, &sP1, &sP2, dwType);
int dwCount = 0;
do {
/* prevent drawing over big ticks */
if (!dwType && !(sSlider->wSTC % sSlider->wBTC))
if (!(dwCount % (sSlider->wSTC / sSlider->wBTC)))
goto ADD_STEP; /* just advance, do not draw */
if (sSlider->wType & SLT_RADIAL) { /* only do this if our slider is a rotary knob */
/* use the rotation matrix to draw the ticks in the same manner the inner circle of the slider is drawn */
D2D1MakeRotateMatrix(flCurrPos, sCenter, &sMatrix);
ID2D1DCRenderTarget_SetTransform(gl_sD2D1Renderer.sDCTarget, &sMatrix);
}
ID2D1DCRenderTarget_DrawLine(gl_sD2D1Renderer.sDCTarget, sP1, sP2, (ID2D1Brush *)gl_sD2D1Renderer.sDCSCBrush, 1.0f, NULL);
ADD_STEP:
flCurrPos += flStep; /* advance current position by previously */
} while (dwCount++ < (dwType ? sSlider->wBTC : sSlider->wSTC));
return ERROR_OK;
}
static int __Slider_Internal_DrawNumbersAndText(Slider *sSlider) {
/* only draw numbers if the option is specified */
if (sSlider->wType & SLO_NUMBERS) {
float flPosX, flPosY;
CHAR strString[8] = { 0 }; /* number string buffer */
SIZE sExtends = { 0 };
/* the same as in "DrawTicks" */
float flAngle = sSlider->sPosRange.flMin;
int dwNumber = sSlider->sNRange.dwMin; /* first number in the number range */
float flAStep = (sSlider->sPosRange.flMax - sSlider->sPosRange.flMin) / sSlider->wBTC; /* angle step */
int dwNStep = (sSlider->sNRange.dwMax - sSlider->sNRange.dwMin) / sSlider->wBTC; /* number step */
do {
/* this should be clear what it does */
sprintf_s(strString, 7, "%i", dwNumber);
GetTextExtentPoint32A(sSlider->_Base.sDraw->hMemDC, strString, (int)strlen(strString), &sExtends);
/* calculate text position around the outer circle */
/* gl_flBTL = big tick length, gl_flTDP = pitch between outer circle edge and tick start */
flPosX = cosf(__toRad(flAngle - 90.0f)) /* deg to rad */ * (__SlR_OR + gl_flTDP + gl_flBTL + 10.0f);
flPosY = sinf(__toRad(flAngle - 90.0f)) * (__SlR_OR + gl_flTDP + gl_flBTL + 10.0f);
TextOutA(
sSlider->_Base.sDraw->hMemDC,
(int)(__SlR_C - flPosX),
(int)(__SlR_C - flPosY - sExtends.cy / 2.0f),
strString,
(int)strlen(strString)
);
flAngle += flAStep;
dwNumber += dwNStep;
/* prevent overdrawing first number when 360 degrees range */
if (dwNumber == sSlider->sNRange.dwMax && sSlider->sPosRange.flMin == 0.0f && sSlider->sPosRange.flMax == 360.0f)
break;
} while (dwNumber <= sSlider->sNRange.dwMax);
}
/* draw the main slider text in the middle at the bottom edge of the control */
/* __Cl* = extends of the client area of the window (X = left, Y = top, W = right, H = bottom) */
TextOut(
sSlider->_Base.sDraw->hMemDC,
(__ClW(sSlider) - __ClX(sSlider)) / 2,
(__ClH(sSlider) - __ClY(sSlider)) / 2 + (int)__SlR_OR + 10,
sSlider->_Text.strText,
sSlider->_Text.dwLengthInChars
);
return TEGTK_ERROR_OK; /* 0 */
}
With certain exemplary values given, it produces this result:
While I find the result visually pleasing and its rendering procedure relatively simple, I think it's slow. I have not noticed any performance issues yet; therefore, I have measured the time it takes to complete an entire draw call. Note that this is done every time the slider's appearance changes due to user input. I have also found that when I move the mouse slowly, the draw calls are way slower than when I move the mouse quickly.
Slow mouse movement:
Fast mouse movement:
The issue is now that I create a separate memory DC for every control instance, which I later draw to using the code above. I have heard that I can only use 10k GDI objects per process. I already use at least 2 per control (a DC and a bitmap). What if I have a really large GUI project with a lot going on? I really do not ever want to run into the limits. That's why I was thinking of moving the paint code entirely into WM_PAINT and using the DC I get from "BeginPaint()" (so no extra memory DC and bitmap needed). Basically forcing an entire repaint when it gets called. That's where the speed issue comes into play as WM_PAINT can be sent really frequently. I know I can smartly repaint only what's needed, but the atomic primitive draw calls do not do a lot when it comes to performance. What takes a lot of time is binding the DC and EndDraw.
I now have a dilemma because I want to be both fast but also not using more GDI objects than I absolutely have to. So not using a separate memory buffer is an option if the draw described above is in-fact not slow objectively.
These are my questions:
- Is my drawing code actually slow or is it okay if redrawing the control takes like 1-5 ms on average?
- What can I do to improve its performance if it's actually slow? (I have tried to buffer as much computational data as I can -- while it essentially doubles the control's memory footprint, it does not really do anything for performance.)
- How is the actual redrawing done in commercially available frameworks such as QT and wxWidgets?
I really hope it's clear what I want. If there are any questions, feel free to ask. It's not only about good code, but also about good design. I want to make sure I do not implement major design flaws that early in the project.
c
winapi
optimization
gdi
direct2d
0 Answers
Your Answer