Vulkan SDF Renderer + Video Recorder + Hot Reloader
VSDF is licensed under GPL-3.0
Quickstart: See QUICKSTART.md for installation and your first shader in minutes.
Shell Integration: (Experimental) See SHELL_INTEGRATION.md for instant shader development with one command.
Render an SDF like ShaderToy using Vulkan and hot reload based on frag shader changes. That way you can use your favourite editor / LSP and also utilise git.
Supports macOS, Linux, and Windows with native file watcher implementations for each platform.
| OS | Support |
|---|---|
| Windows | ✅ Supported |
| Linux | ✅ Supported |
| macOS | ✅ Supported |
Easiest way to install vsdf:
This will install MoltenVK
brew install jamylak/vsdf/vsdfFor building from source or manual dependency management: Install Vulkan + deps with Homebrew:
brew install molten-vk vulkan-loader glslang glfw glm spdlog vulkan-tools googletest ffmpeg
# Note: FFmpeg is optional; set `-DDISABLE_FFMPEG=ON` (see `CMakeLists.txt`)https://vulkan.lunarg.com/sdk/home
Then follow the steps to do sudo ./install_vulkan.py in SDK System Paths section
VULKAN_SDK $HOME/VulkanSDK/1.4.328.1/macOS
Pre-built binaries for Linux are available in the GitHub Releases page.
Download the latest vsdf-linux binary, make it executable, and move it to a directory in your PATH.
The only dependency is Vulkan.
LATEST_RELEASE_TAG=$(curl -sL https://api.github.com/repos/jamylak/vsdf/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
LINUX_ARCH=$( [ "$(uname -m)" = "aarch64" ] || [ "$(uname -m)" = "arm64" ] && echo arm64 || echo x86_64 )
LINUX_DIR="linux-${LINUX_ARCH}"
DOWNLOAD_URL="https://github.com/jamylak/vsdf/releases/download/${LATEST_RELEASE_TAG}/vsdf-linux-${LINUX_ARCH}.tar.gz"
echo "Downloading from: ${DOWNLOAD_URL}"
curl -LO "${DOWNLOAD_URL}"
tar -xzf vsdf-linux-${LINUX_ARCH}.tar.gz
chmod +x ${LINUX_DIR}/vsdf
sudo mv ${LINUX_DIR}/vsdf /usr/local/bin/vsdf
rm -rf vsdf-linux-${LINUX_ARCH}.tar.gz ${LINUX_DIR} # Clean up downloaded filesPre-built Windows binaries (built without ffmpeg) are available on the GitHub Releases page.
Download the latest release zip and run vsdf.exe from the extracted folder.
$tag = (Invoke-RestMethod https://api.github.com/repos/jamylak/vsdf/releases/latest).tag_name
$zip = "vsdf-windows-x86_64-disable_ffmpeg.zip"
$url = "https://github.com/jamylak/vsdf/releases/download/$tag/$zip"
Invoke-WebRequest -Uri $url -OutFile $zip
Expand-Archive $zip -DestinationPath vsdf
.\vsdf\vsdf.exe --versionInstall dependencies:
sudo apt-get update
sudo apt-get install -y \
build-essential cmake ninja-build \
libgtest-dev libspdlog-dev \
libglfw3 libglfw3-dev libvulkan-dev \
glslang-tools glslang-dev libglm-dev \
# (Optional) set -DDISABLE_FFMPEG=ON to skip \
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \- Install vcpkg (if not already installed):
git clone https://github.com/Microsoft/vcpkg.git cd vcpkg .\bootstrap-vcpkg.bat
- Install dependencies using vcpkg (includes Vulkan):
vcpkg install vulkan:x64-windows glfw3:x64-windows glslang:x64-windows spdlog:x64-windows glm:x64-windows gtest:x64-windows # Note: ffmpeg is optional; set `-DDISABLE_FFMPEG=ON` (see `CMakeLists.txt`) to build without it vcpkg install ffmpeg[avcodec,avformat,swscale]:x64-windows vcpkg integrate install
# ffmpeg is optional; set `-DDISABLE_FFMPEG=ON` (see `CMakeLists.txt`) to build without it.
git submodule update --init --recursive
cmake -B build .
cmake --build build
./build/vsdf {filepath}.frag# ffmpeg is optional; set `-DDISABLE_FFMPEG=ON` (see `CMakeLists.txt`) to build without it.
git submodule update --init --recursive
cmake -B build -DCMAKE_TOOLCHAIN_FILE="C:/vcpkg/scripts/buildsystems/vcpkg.cmake" .
cmake --build build --config Release
.\build\Release\vsdf.exe {filepath}.fragTo make vsdf available as a command in your shell, you can install it to a standard system directory like /usr/local.
Linux/macOS
# Pick somewhere in your PATH and install it there
cmake --install build --prefix /usr/local
vsdf {filepath}.fragOn Windows, you can also install to a custom location and add that location to your PATH environment variable to make it accessible from any command prompt.
vsdf --new-toy example.frag
vsdf --toy example.fragvsdf --toy example.frag --frames 100 --ffmpeg-output out.mp4vsdf --toy example.fragNote: That if you use --toy we will prepend a template
which links up the push constants
like iTime and we will also follow this logic.
if (useToyTemplate) {
Client = glslang::EShClientOpenGL;
ClientVersion = glslang::EShTargetOpenGL_450;
} else {
Client = glslang::EShClientVulkan;
ClientVersion = glslang::EShTargetVulkan_1_0;
}--helpShow this help message--versionShow version information--new-toy [name]Create a new shader file with starter template. Prints the filename and exits. Generates random name like my_new_toy_12345.frag if not provided.--template <name>Template to use with--new-toy(default, plot)--toyUse ShaderToy-style template wrapper--no-focusDon't steal window focus on startup and float--headlessHide the GLFW window (pair withxvfb-runin CI)--frames <N>Render N frames then exit--log-level <trace|debug|info|warn|error|critical|off>Setspdlogverbosity (default: info)--debug-dump-ppm <dir>Copy the swapchain image before present (adds a stall); mainly for smoke tests or debugging--ffmpeg-output <file>Enable offline encoding; output file path (requires--frames)--ffmpeg-fps <N>Output FPS (default: 30)--ffmpeg-crf <N>Quality for libx264 (default: 20; lower is higher quality)--ffmpeg-preset <name>libx264 preset (default: slow)--ffmpeg-codec <name>FFmpeg codec (default: libx264)--ffmpeg-width <N>Output width (default: 1280)--ffmpeg-height <N>Output height (default: 720)--ffmpeg-ring-buffer-size <N>Ring buffer size for offline render (default: 2)
git submodule update --init --recursive
cmake -B build -DBUILD_TESTS=ON -DDEBUG=ON
cmake --build build
./build/tests/vsdf_tests
./build/tests/filewatcher/filewatcher_testsgit submodule update --init --recursive
cmake -B build -DBUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_TOOLCHAIN_FILE="C:/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake" .
cmake --build build --config Debug
.\build\tests\vsdf_tests\Debug\vsdf_tests.exe
.\build\tests\filewatcher\Debug\filewatcher_tests.exenix developRun without installing (builds and runs):
nix run github:jamylak/vsdf -- --new-toy example.frag
nix run github:jamylak/vsdf -- --toy example.fragInstall into your profile (then vsdf is on PATH):
nix profile install github:jamylak/vsdfOpen a one-off shell with vsdf available:
nix shell github:jamylak/vsdf# flake.nix
{
inputs.vsdf.url = "github:jamylak/vsdf";
}# home.nix
{ inputs, pkgs, ... }:
{
home.packages = [
inputs.vsdf.packages.${pkgs.system}.default
];
}The one-line mental model:
*.frag→ GLSL/SPIR-V → fullscreen draw → GPU image → present or mapped staging buffer → FFmpeg.
flowchart LR
S[📝 shader.frag] -->|glslang| P[⚙️ SPIR-V pipeline]
P --> G[🎮 fullscreen draw]
G --> R{output route}
R -->|interactive| W[🖥️ acquire swapchain image]
W --> WP[🎨 render into acquired image]
WP --> PR[📺 present rendered image]
R -->|offline| I[🌈 offscreen image]
I --> B[🔁 ring of reusable slots]
B --> H[📦 mapped staging bytes]
H --> E[🎞️ FFmpeg → MP4]
classDef shader fill:#172a46,stroke:#61dafb,color:#fff
classDef gpu fill:#2a194b,stroke:#bd7cff,color:#fff
classDef io fill:#173c3a,stroke:#b7f34a,color:#fff
class S shader
class P,G,R,I gpu
class W,WP,PR,B,H,E io
OFFLINE SLOT CAROUSEL · default: 2 slots · configurable: 1..10
┌───────┐ ┌───────┐ ┌───────┐
│ GPU │──────▶│ COPY │──────▶│ ENCODE│
│ draw │ │ image │ │ frame │
└───┬───┘ └───────┘ └───┬───┘
│ │
└────────── fence + reuse ◀─────┘
[ slot 0 ] → [ slot 1 ] → [ slot 0 ] → …
each slot = device-local image + host-visible staging buffer
slot reuse waits for both GPU completion and encode completion
VSDF is deliberately a thin pipeline: one fragment shader, one fullscreen draw, and a small amount of Vulkan orchestration around it. The important fork is whether the finished image goes to a window (online) or into a reusable GPU/CPU pipeline (offline).
flowchart LR
A[📝 .frag shader] --> B{--toy?}
B -->|yes| C[🧩 prepend ShaderToy wrapper<br/>iTime · iFrame · iResolution · iMouse]
B -->|no| D[📄 use shader source as-is]
C --> E[glslang parse + link]
D --> E
E --> F[⚙️ SPIR-V in memory]
F --> G[VkShaderModule]
G --> H[🎛️ Graphics pipeline<br/>embedded fullscreen-quad vertex shader]
H --> I{render mode}
I --> J[🖥️ Online: swapchain image]
I --> K[🎞️ Offline: offscreen image]
J --> L[Present → window]
K --> M[Image → staging buffer]
M --> N[Mapped CPU bytes]
N --> O[FFmpeg swscale → YUV420P → H.264/MP4]
classDef input fill:#18233f,stroke:#61dafb,color:#fff
classDef gpu fill:#271849,stroke:#bd7cff,color:#fff
classDef io fill:#173c3a,stroke:#b7f34a,color:#fff
class A,B,C,D input
class E,F,G,H,J,K gpu
class L,M,N,O io
| Stage | What happens | Where to read it |
|---|---|---|
| 1 · CLI | Chooses online vs offline; validates shader and export flags | src/main.cpp |
| 2 · Source | Reads .frag; optionally prepends the ShaderToy adapter |
src/shader_utils.cpp |
| 3 · Compile | glslang parses + links, then emits SPIR-V directly in memory | src/shader_utils.cpp |
| 4 · Device | Creates Vulkan instance, picks a GPU, logical device, queue, render pass | src/online_sdf_renderer.cpp, src/offline_sdf_renderer.cpp |
| 5 · Pipeline | Loads embedded fullscreen-quad vertex shader + user fragment shader | include/vkutils.h, src/*_sdf_renderer.cpp |
| 6 · Frame | Pushes animation/input constants and records six vertices | src/online_sdf_renderer.cpp, src/offline_sdf_renderer.cpp |
| 7 · Output | Presents, or copies image bytes to host-visible memory | include/vkutils.h, src/offline_sdf_renderer.cpp |
| 8 · Encode | Converts BGRA/RGBA → YUV420P and writes packets | src/ffmpeg_encoder.cpp |
main.cpp parses the command line. Normal use creates OnlineSDFRenderer. Adding --ffmpeg-output switches to OfflineSDFRenderer and requires --frames, because the offline lane is a finite frame producer rather than a window loop.
flowchart TD
A[argv] --> B[validate .frag + flags]
B --> C{FFmpeg output path?}
C -->|no| D[OnlineSDFRenderer]
C -->|yes| E[OfflineSDFRenderer]
D --> F[setup → gameLoop]
E --> G[setup → renderFrames]
F --> H[interactive window]
G --> I[finite video file]
The user shader is not read once and forgotten: the file watcher can trigger a safe pipeline rebuild while the old pipeline remains active if compilation fails.
shader.frag
│
├─ --toy ──► template prefix
│ ├─ PushConstants pc
│ ├─ #define iTime / iFrame / iResolution / iMouse
│ └─ mainImage(fragColor, flipped- Y gl_FragCoord)
│
└──────────► glslang::TShader
│ parse → link → GlslangToSpv
▼
vector<uint32_t> (SPIR-V in memory)
│
├─ VkShaderModule(fragment)
└─ VkPipeline(fullscreen vertex + fragment)
The vertex shader is embedded and has no vertex buffer: it generates six vertices, which are submitted as two triangles covering the rectangular viewport. That means the fragment shader does the interesting work once per output pixel, with no mesh upload or scene graph in the hot loop.
The rectangle is split by one diagonal. There are exactly two triangles:
A (-1, 1) ┌──────────────┐ C (1, 1)
│╲ triangle 2│
│ ╲ │
│ ╲ │
│ ╲ │
B (-1, -1) └────╲─────────┘ D (1, -1)
triangle 1
triangle 1 = B → D → C
triangle 2 = B → C → A
Why two triangles in this repo? Because FULLSCREEN_QUAD_VERT_SOURCE explicitly lists those six vertices and vkCmdDraw(6, 1, 0, 0) draws them as two triangle primitives. A rectangle needs two triangles when triangles are the chosen primitive: the shared diagonal tessellates the rectangle exactly, so every pixel in the viewport is covered once.
Could this be one giant triangle? Yes. Nothing else in the renderer makes two triangles mandatory. A three-vertex triangle placed partly outside the viewport would be clipped to cover the screen and would produce the same fragment coverage. It can save one vertex and avoid the shared diagonal, but that is a tiny optimization compared with running the fragment shader for every pixel. The current two-triangle version is simply the conventional, more immediately readable fullscreen quad; switching to one triangle would be a valid future optimization, not a correctness fix.
Each frame is described by a tiny push-constant packet. It is copied into the command buffer immediately before vkCmdDraw(6, 1, 0, 0).
┌─────────────────────────────────────────────┐
│ PushConstants │
│ iTime float elapsed seconds │
│ iFrame uint monotonically rising │
│ iResolution vec2 output width/height │
│ iMouse vec2 cursor or (-1000,-1000)│
└─────────────────────────────────────────────┘
│
▼
fragment shader, per pixel
The online renderer gets time from GLFW. The offline renderer uses deterministic time: currentFrame / fps. This is the detail that makes an exported sequence line up with its requested FPS rather than wall-clock jitter.
The loop in OnlineSDFRenderer::gameLoop() is the interactive hot path:
sequenceDiagram
participant CPU as CPU loop
participant W as GLFW/window
participant Q as Vulkan queue
participant GPU as GPU
participant S as swapchain
CPU->>W: glfwPollEvents()
CPU->>CPU: wait fence[frameIndex] (blocks CPU until prior GPU work finishes)
CPU->>S: vkAcquireNextImageKHR(waiting on imageAvailable semaphore)
S-->>CPU: imageIndex
CPU->>CPU: record command buffer + push constants
CPU->>Q: vkQueueSubmit(wait imageAvailable, signal renderFinished + fence)
Q->>GPU: execute fullscreen draw
GPU->>S: render into acquired swapchain image[index]
GPU-->>Q: signal renderFinished semaphore + fence
CPU->>Q: vkQueuePresentKHR(wait renderFinished, imageIndex)
Q->>S: present rendered image
CPU->>CPU: frameIndex = (frameIndex + 1) % imageCount
The synchronization story is intentionally visible:
- Fence = CPU waits until this frame slot is finished.
- Image-available semaphore = submission waits until the swapchain image is acquired.
- Render-finished semaphore = presentation waits until GPU rendering is done.
- Swapchain = the platform-owned set of images displayed by the window.
Code-reading note:
frameIndexselects the CPU-in-flight synchronization slot;imageIndexis returned byvkAcquireNextImageKHRand selects the swapchain image/command buffer. They are different concepts even when their values happen to match. The current implementation should keep theimageAvailable/renderFinishedsemaphore index consistent across acquire, submit, and present; inspect those calls carefully when changing this loop.
Resize and shader edits are slow-path events. A resize tears down/rebuilds the swapchain context; a shader edit waits for the device, destroys only the fragment pipeline objects, recompiles, and recreates the pipeline. A failed compile logs a warning and keeps the previous pipeline alive.
Offline rendering replaces the swapchain with ringSize offscreen images. The default is 2 slots; the hard storage ceiling is 10 slots (MAX_FRAME_SLOTS). Every slot owns the complete chain below:
slot[i]
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ VkImage │ → │ VkImageView │ → │ VkFramebuffer │
│ DEVICE_LOCAL │ │ color view │ │ render-pass target │
└──────────────┘ └──────────────┘ └──────────────────────┘
│ GPU copy: image → buffer
▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ staging VkBuffer │ → │ mappedData (host-visible) │
│ TRANSFER_DST │ │ encoder reads without memcpy │
└──────────────────────┘ └──────────────────────────────┘
One offline iteration is:
- Pick
slotIndex = currentFrame % ringSize. - Wait until that slot’s previous encode is finished (
pendingEncode == false). - Reset its fence and record a one-time command buffer.
- Render the fullscreen draw into the slot image.
- Timestamp the GPU work: top-of-pipe → bottom-of-pipe.
- Barrier
COLOR_ATTACHMENT_OPTIMAL → TRANSFER_SRC_OPTIMAL. vkCmdCopyImageToBufferinto the slot’s staging buffer.- Barrier back to
COLOR_ATTACHMENT_OPTIMALfor the next reuse. - Submit and enqueue
{slotIndex, frameIndex}for the encoder thread.
flowchart LR
subgraph GPU[🎮 GPU lane]
R[render slot 0] --> C[copy image → staging buffer]
C --> F[fence signals]
F --> N[reuse slot after encode]
end
subgraph CPU[🧵 encoder lane]
Q[condition-variable queue] --> W[wait slot fence]
W --> S[sws_scale BGRA/RGBA → YUV420P]
S --> X[avcodec_send_frame]
X --> P[receive + mux packet]
P --> U[clear pendingEncode]
end
F --> Q
U --> N
N --> R
This is the core speed-up: while the CPU encodes frame N, the GPU can render frame N+1 into another slot. With one slot, producer and consumer serialize. With K ≥ 2, the steady-state time approaches max(render time, encode time) rather than render time + encode time.
1 slot: [ GPU 0 ][ encode 0 ][ GPU 1 ][ encode 1 ] ← bubbles / no overlap
2 slots: [ GPU 0 ][ GPU 1 ][ GPU 0 ][ GPU 1 ]
└── encode 0 ──┘ └── encode 1 ──┘ ← overlap
The condition variable provides back-pressure in both directions: the encode queue cannot grow beyond ringSize, and the GPU cannot overwrite a slot still owned by the encoder. That is the optimization without the correctness trap.
The encoder thread consumes the mapped staging bytes directly. For the default BGRA8 offscreen format, FfmpegEncoder tells FFmpeg the source layout and stride, then performs the only required format conversion:
mapped BGRA/RGBA bytes
│ sws_scale (bicubic)
▼
YUV420P ──► avcodec_send_frame ──► receive packets ──► mux ──► out.mp4
PTS = frameIndex FPS = --ffmpeg-fps
The encoder sets a one-frame timebase (1 / fps), PTS = frameIndex, duration = 1, GOP = fps, and supports codec/preset/CRF overrides. At shutdown it flushes delayed codec frames, writes the trailer, and joins the encoder thread before Vulkan teardown.
| Choice | Why it helps | Cost / gotcha |
|---|---|---|
| Fullscreen draw with embedded vertex shader | No vertex-buffer upload; tiny command recording path | Every pixel is shader work; the fragment shader is the hot computation |
| Push constants | Tiny per-frame update, no descriptor-set allocation | Payload is intentionally small: time, frame, resolution, mouse |
| SPIR-V compiled in memory | No intermediate shader file or disk round-trip | Compile/link still happens on startup and hot reload |
| Reusable offline ring slots | GPU and CPU overlap; bounded memory | More slots consume more device-local + host-visible memory |
| Host-visible, host-coherent staging buffers | Encoder reads mapped bytes directly | Device→host transfer still exists; it is explicit and synchronized |
| Per-slot fences + condition variable | Safe reuse and bounded queue/back-pressure | A slow encoder eventually makes the producer wait — by design |
Swapchain image count capped at MAX_FRAME_SLOTS |
Fixed-size arrays avoid unnecessary heap/vector churn | Hardware image count is bounded to the fixed maximum |
| GPU timestamps + CPU wall time | Window title exposes GPU/CPU cost together | Timestamp query reads wait for results; it is observability, not free |
Optional --debug-dump-ppm |
Easy smoke-test/readback proof | Intentionally stalls the online path; use for debugging, not benchmarks |
- If GPU ms dominates: inspect SDF iterations, branching, resolution, and expensive texture/noise operations in the
.frag. - If CPU ms dominates online: inspect synchronization, resize/rebuild events, logging, or debug readback.
- If offline export backs up: compare GPU render time with FFmpeg encode time; increase
--ffmpeg-ring-buffer-sizeup to the useful point, but remember the ring only hides imbalance — it cannot make the slower stage faster. - Benchmark without
--debug-dump-ppm; the flag adds a blocking copy/readback by design.
Use this order when explaining the project to someone (or to future-you):
① src/main.cpp
“Where does the mode fork happen?”
↓
② src/shader_utils.cpp + include/shader_templates.h
“How does a ShaderToy-ish file become SPIR-V?”
↓
③ include/vkutils.h
“What does the minimal Vulkan machinery look like?”
↓
④ src/online_sdf_renderer.cpp
“How do acquire → draw → submit → present synchronize?”
↓
⑤ include/offline_sdf_renderer.h
“What does one ring slot own?”
↓
⑥ src/offline_sdf_renderer.cpp
“Where does overlap, back-pressure, and reuse happen?”
↓
⑦ src/ffmpeg_encoder.cpp
“How do mapped pixels become timestamped packets?”
Useful commands for the tour:
# Interactive: watch a shader while editing it
vsdf --toy example.frag
# Offline: render exactly 100 deterministic frames
vsdf --toy example.frag --frames 100 --ffmpeg-output out.mp4
# Make the synchronization visible with verbose logs
vsdf --log-level debug --toy example.frag --frames 3
# Validate the GPU→CPU copy path (debug only; adds a stall)
vsdf --headless --frames 2 --debug-dump-ppm debug-frames example.frag🧠 Tiny glossary
- SDF — signed distance field; the shader describes shape boundaries as a distance function.
- SPIR-V — Vulkan’s binary shader representation.
- Swapchain — images owned by the presentation system and rotated through the window.
- Staging buffer — a host-visible Vulkan buffer used here as the GPU→CPU hand-off.
- Fence — CPU-visible completion signal for submitted GPU work.
- Semaphore — GPU submission/presentation ordering signal.
- Ring buffer — a bounded set of reusable slots, indexed modulo
ringSize. - Readback — copying rendered pixels out of a GPU image so CPU code can inspect/encode them.
If you don't want any template prepended or you want
to use a Vulkan shader directly you can copy shaders/vulktemplate.frag
and adjust it to your liking
- See
shaders/vulktemplate.fragto see how push constants are passed in
# Then call it without the --toy flag
vsdf path/to/shader.fragThe binary uses volk to dynamically find Vulkan at runtime, so it works regardless of installation location.
- https://shadertoy.com
- https://iquilezles.org/
- https://www.youtube.com/playlist?list=PL0JVLUVCkk-l7CWCn3-cdftR0oajugYvd (zeux)
- https://github.com/SaschaWillems/Vulkan
(I should try follow this but haven't gotten through it all yet) https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines
