Real-time 3DCG demo with the Vulkan rasterizer in Delphi / FireMonkey.
A graphics pipeline built from two GLSL shaders — compiled to SPIR-V at runtime by an embedded glslang — draws a lit, spinning cube, and a swapchain presents it straight into a FireMonkey form through a child HWND.
Drag with the left button to orbit the camera; spin the wheel to dolly in and out.
- LUX :The foundation math library of LUXOPHIA.
- LUX.Vulkan :A wrapper library for the Vulkan API.
- Full graphics-pipeline path of Vulkan: render pass, graphics pipeline, vertex / index buffers, push constants, depth buffer, surface and swapchain.
- Scene graph: the application never touches a Vulkan handle. It builds a tree of
TVkScener/TVkCube3D/TVkCameraPers3Dnodes and assigns matrices; drawing, matrix accumulation and change propagation are handled by theLUX.Vulkanlibrary. - Runtime GLSL compilation:
Shader.vert/Shader.fragare plain GLSL files, compiled on load byglslang.dll[5]. No offline step, no Vulkan SDK, no CMake — edit a file and restart the app. - Change-driven redraw: assigning one matrix (
_Cube.LocalPose := …) propagates up the tree and redraws the viewer automatically; no explicit render call is needed. - Two presentation paths: direct presentation into a bare
WS_CHILDwindow over the frame (TVkSwaper), or an offscreen render copied into aTBitmap(TVkRender) that obeys FireMonkey's paint order — switchable at runtime. - No descriptor sets: all per-object data travels in a 128-byte push constant, exactly the minimum size every Vulkan implementation must support [1].
Vulkan exposes the GPU as an explicit object chain [1][2]:
- Instance → physical device:
TVulkancreates theVkInstanceand enumerates theVkPhysicalDevices (TVulkan.Devices). - Logical device:
TVkContexcreates aVkDeviceon one physical device, selecting a graphics-capable queue family and enablingVK_KHR_swapchainby default. - Queue:
TVkQueuerwraps aVkQueueof that family plus aVkCommandPool; it records, submits, and presents. - Render pass:
TVkPasserdescribes the most basicVkRenderPass— one color attachment, one depth attachment, one subpass, both cleared on load (loadOp = CLEAR). For presentation its final color layout isVK_IMAGE_LAYOUT_PRESENT_SRC_KHR. - Graphics pipeline:
TVkRaster3Dbuilds theVkPipelinefrom the two shader stages, the fixedTVkVertex3Dvertex layout, and theTVkPush3Dpush-constant range. Pipelines are reusable across compatible render passes (same attachment formats and sample counts), so one pipeline serves both presentation paths. - Swapchain:
TVkSwaperturns anHWNDinto aVkSurfaceKHR+VkSwapchainKHRand runs a one-frame-in-flight loop: acquire → record → submit → present → wait.
Every scene node carries a local pose matrix; GlobalPose is the product of all ancestor poses. With the model matrix TVkCamera.ViewMat), and the projection
The vertex shader receives the two products it needs as push constants (TVkPush3D): ProjViewPose Pose
Vulkan's clip space differs from OpenGL's: the framebuffer VkProjPers3D / VkProjOrth3D) rather than converting the OpenGL-style ones from LUX.D4x4. For a camera screen of physical size TVkCameraPers3D.FocusZ), with near / far planes
The negated second row realizes the Y-down convention, and the third row maps the view-space depth range AngleY moves the focal distance while the screen stays put,
The camera's screen aspect (VkFitViewport letterboxes the VkViewport so the camera's field lands centered and undistorted inside the target; the bars need no extra drawing because the render pass has already cleared the whole framebuffer to the scene's background color.
The two stages are ordinary GLSL files, compiled at load time by the embedded glslang [5]:
layout( push_constant ) uniform Push { mat4 ProjViewPose; mat4 Pose; } uPush;
layout( location = 0 ) in vec3 aPos;
layout( location = 1 ) in vec3 aNor;
layout( location = 2 ) in vec2 aTex;The 128-byte TVkPush3D block is exactly maxPushConstantsSize's guaranteed minimum [1], so the demo needs no descriptor set at all. The fragment shader tints each face by its world-space normal (
Actual classes and ownership (LUX.Vulkan); arrows ─▶ denote references, indented lines denote ownership:
[ Core objects — indentation = ownership; wrapped Vulkan handle on the right ]
・TVulkan
┗・TVkSystem ・・・ VkInstance
┗・TVkDevice ・・・ VkPhysicalDevice
┗・TVkContex ・・・ VkDevice (logical device)
┣・TVkQueuer ・・・ VkQueue + VkCommandPool (submit / present)
┣・TVkShader ×2 ・・・ VkShaderModule (GLSL → SPIR-V by glslang)
┣・TVkRaster3D ・・・ VkPipeline + Layout
┗・TVkPasser ・・・ VkRenderPass (color + depth, 1 subpass)
[ References — not owned ]
・TVkRaster3D
┗・Stagers ・・・ references the 2 TVkShader instances
[ Scene graph — owned as a tree; Free releases the whole subtree ]
・TVkScener ・・・ root: Contex / Queuer / BackColor, OnChange
┣・TVkCube3D ・・・ TVkShaper3D: TVkVerBuf3D(24)+TVkIndBuf3D(36)
┃ ┗・Raster ・・・ references TVkRaster3D (inherited, not owned)
┗・TVkCameraPers3D ・・・ TVkCamera3D: ProjMat/ViewMat, OnChange→OnScene
[ Presentation — TVkViewer = TFrame on the form ]
・TVkViewer
┣・_Passer :TVkPasser ・・・ ColorLast = PRESENT_SRC_KHR
┣・Direct = True
┃ ┗・_Window :HWND (WS_CHILD)
┃ ┗・TVkSwaper ・・・ VkSurfaceKHR+VkSwapchainKHR+depth attachment
┗・Direct = False
┗・TVkRender ・・・ offscreen TBitmap, drawn by Canvas.DrawBitmap
Data flow of one frame:
[ One frame — nesting shows what happens inside the step above ]
・_Cube.LocalPose := … ・・・ (the only line the demo writes)
┗・Changed climbs the tree
┗・TVkScener.OnChange
┗・TVkCamera.OnScene
┗・TVkViewer.Render
┣・TVkSwaper.BeginFrame ・・・ acquire image
┣・VkRecordScene
┃ ┣・begin render pass ・・・ clear color + depth
┃ ┣・letterbox viewport
┃ ┗・TVkCamera.Render ・・・ ProjView := ProjMat × ViewMat
┃ ┗・TVkScener.Draw ・・・ accumulate poses (TVkDrawer state)
┃ ┗・TVkCube3D.DrawMain
┃ ┣・BindRaster
┃ ┣・vkCmdPushConstants
┃ ┗・vkCmdDrawIndexed
┗・TVkSwaper.EndFrame
┣・submit
┣・present
┗・wait
The scene is a tree of TVkObject nodes (TTreeKnot); TVkScener is a node that refuses any parent, so the root is found by simply walking up. Create( Parent ) joins the tree, Free releases a whole subtree, and cycles are rejected by the tree layer. A pipeline (Raster) set on any node is inherited by its descendants; nodes reference it but never own it.
Redrawing is not driven by the timer. Any mutation — pose, size, visibility, insertion or removal — travels up the tree as Changed, leaves the scene through TVkScener.OnChange (a multicast delegate), is forwarded by the subscribed camera as OnScene, and lands on the viewer, which re-records and re-presents the frame. The whole animation is therefore one assignment per tick:
procedure TForm1.Timer1Timer(Sender: TObject);
begin
_Spin := _Spin + 0.012;
_Cube.LocalPose := TSingleM4.RotateY( _Spin ) * TSingleM4.RotateX( _Spin * 0.6 );
end;FireMonkey paints a whole form into a single HWND, so there is no natural seam for Vulkan to present into. TVkViewer (a TFrame) therefore creates a bare WS_CHILD window — not a TForm — over its own rectangle and presents into it through TVkSwaper. Being a child of the form's HWND, it is clipped by the form and follows it through moves, minimization and destruction; no z-order or activation bookkeeping is needed. The child window returns HTTRANSPARENT from WM_NCHITTEST, so it swallows no input: the mouse reaches FireMonkey as usual, and the orbit / zoom handlers are plain OnMouseDown / OnMouseMove / OnMouseWheel events on the frame. It also ignores WM_ERASEBKGND and validates WM_PAINT without drawing, since Vulkan owns every pixel.
With Direct := False the viewer switches to TVkRender, which renders offscreen and blits a TBitmap in Paint. That path obeys FireMonkey's paint order — controls can be layered on top — at the cost of a GPU → CPU → GPU round trip per frame. TVkSwaper runs one frame in flight and treats VK_ERROR_OUT_OF_DATE_KHR / VK_SUBOPTIMAL_KHR as rebuild signals; while minimized, Ready turns False and frames are skipped entirely.
・Vulkan3DCG/
┣・Vulkan3DCG.dpr / .dproj ・・・ FireMonkey application project (Win32/Win64)
┣・Main.pas / Main.fmx ・・・ TForm1: construction + orbit-camera input
┣・_DATA/
┃ ┣・Shader.vert ・・・ vertex stage: push constants, aPos/aNor/aTex
┃ ┗・Shader.frag ・・・ fragment stage: normal tint+checker+diffuse
┗・_LIBRARY/LUXOPHIA/
┣・LUX/ ・・・ base library: TSingle3D, TSingleM4, tree/list
┗・LUX.Vulkan/ ・・・ Vulkan wrapper library (git subtree)
┣・Core/ ・・・ Contex / Queuer / Shader / Passer / Raster …
┣・Graphics/ ・・・ scene graph, Swaper, Render, Viewer
┣・Glslang/ + _DLL/ ・・・ glslang bindings + glslang.dll [5]
┗・Vulkan/ ・・・ Vulkan API translation (vulkan_core, …)
| Control | Action |
|---|---|
| Left-drag on the view | Orbit the camera around the origin (polar coordinates, pitch clamped to ±1.5 rad) |
| Mouse wheel | Dolly: distance × 1.1 per notch, clamped to [1.2, 40] |
| Direct checkbox | On: present via the child-window swapchain / Off: offscreen TVkRender → TBitmap |
| Rotate ( TTimer ) checkbox | Start / stop the cube's spin |
The memo on the left reports the selected device and Vulkan version, the queue family and enabled extensions, the shader compile results with SPIR-V sizes, the pipeline build status, and the camera's screen / focal length / field of view.
- IDE: RAD Studio / Delphi (FireMonkey project,
ProjectVersion 20.4). - Platforms: Windows 32-bit / 64-bit (
Win32/Win64targets inVulkan3DCG.dproj). - Runtime requirement: a GPU driver providing the Vulkan runtime (
vulkan-1.dll). The Vulkan SDK is not required — the API translation lives in_LIBRARY/LUXOPHIA/LUX.Vulkan/Vulkan/, and the loader is bound at runtime; without Vulkan the app starts and reports "Vulkan is not available." - Shader compilation: none at build time. The GLSL sources in
_DATA/are compiled to SPIR-V on startup byglslang.dll[5], which a post-build event copies from_LIBRARY/LUXOPHIA/LUX.Vulkan/_DLL/$(Platform)/next to the executable. Editing a shader only requires restarting the app.
Open Vulkan3DCG.dproj, select a platform, and run.
- Khronos Group, Vulkan Specification
- Khronos Group, Vulkan Guide
- Khronos Group, Vulkan Registry
- Khronos Group, Vulkan-Headers
- Khronos Group, glslang
Integrated Development Environment (IDE) for Creating Native Cross-Platform Apps.
