13 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
This is a cross-platform Shadertoy-like fragment shader viewer built with SDL3. It supports two rendering backends:
- SDL3 GPU (default): renders via Vulkan on Linux/Windows and Metal on macOS.
- OpenGL 3.3 (fallback): the original backend, used automatically if SDL3 GPU init fails.
Selection at launch:
./shadertoy --backend=gpu # force SDL3 GPU
./shadertoy --backend=opengl # force OpenGL
./shadertoy --backend=auto # default: GPU first, fall back to OpenGL on failure
Build and Development Commands
Building (CMake - Development)
mkdir build && cd build
cmake ..
cmake --build . --config Release
Building (Makefile - Release Packages)
make windows_release # Creates .zip with DLLs
make macos_release # Creates .dmg with app bundle
make linux_release # Creates .tar.gz
make show_version # Display build version (YYYY-MM-DD format)
Platform-specific debug builds:
make windows_debug
make macos_debug
make linux_debug
Compiling Shaders to SPIR-V
The SDL3 GPU backend reads compiled SPIR-V (.frag.spv) at runtime. Run this after touching any .vk.glsl file:
cmake --build build --target compile_shaders
Requires glslc (Debian/Ubuntu: apt install glslang-tools; macOS: brew install glslang).
Running the Application
./shadertoy [SHADER_NAME_OR_PATH] [-F|--fullscreen] [--backend=auto|gpu|opengl]
# Examples:
./shadertoy test # auto backend, shaders/test/
./shadertoy --backend=gpu seascape # force SDL3 GPU (Vulkan/Metal)
./shadertoy -F shaders/fractal_pyramid # explicit folder path, fullscreen
Runtime Controls:
ESC- ExitF3- Toggle fullscreenLEFT/RIGHT ARROW- Cycle through shaders in directory
Architecture
Layered design
src/main.cpp— SDL3 window, event loop, shader-list scanning, dispatch to a backend.src/rendering/shader_backend.hpp— abstractIShaderBackendinterface +ShaderMetadata/ShaderUniforms/ShaderProgramSpecshared types. Two factories:makeOpenGLBackend(),makeSdl3GpuBackend().src/rendering/opengl_shader_backend.{hpp,cpp}— OpenGL 3.3 implementation. Loads<folder>/<name>.gl.glslat runtime.src/rendering/sdl3gpu/sdl3gpu_shader_backend.{hpp,cpp}— SDL3 GPU implementation. Loads<folder>/<name>.frag.spv(Linux/Windows) or<folder>/<name>.frag.msl(macOS); shares one passthrough vertex shader fromshaders/_common/passthrough.vert.{spv,msl}.src/rendering/sdl3gpu/shader_factory.hpp— small helper that loads a shader binary/source from disk and creates anSDL_GPUShader.
Shader folder layout
One folder per shader, under shaders/:
shaders/<name>/
<name>.gl.glsl # OpenGL GLSL 3.30 (handwritten)
<name>.vk.glsl # Vulkan GLSL 4.50 (handwritten)
<name>.frag.msl # Metal Shading Language (handwritten)
<name>.frag.spv # generated by `make compile_shaders` from .vk.glsl
meta.txt # Name / Author / iChannelN
shaders/_common/
passthrough.vk.glsl # shared fullscreen-triangle vertex (Vulkan)
passthrough.vert.spv # generated
passthrough.vert.msl # handwritten Metal
Folders starting with _ or . are skipped by the scanner. The meta.txt parser is in Rendering::parseMetaFile.
Backend selection logic (main.cpp)
- Parse
--backend=...flag (defaultauto). - If not OpenGL: create a window with no flags, instantiate
Sdl3GpuShaderBackend. Ifinit()fails AND choice wasauto, destroy the window and continue. If choice wasgpu, exit with error. - Otherwise: create a window with
SDL_WINDOW_OPENGLand instantiateOpenGLShaderBackend.
Global State (main.cpp)
shader_list_ // Vector<ShaderEntry { folder, base_name }>
current_shader_index_ // Active shader in rotation
shader_start_ticks_ // Base time for iTime uniform calculation
window_ // SDL3 window pointer
backend_ // unique_ptr<IShaderBackend>
shaders_directory_ // Shaders root path (resolved at startup)
Dependencies
- SDL3 ≥ 3.2 — windowing, events, GPU API (
SDL_gpu.h) - GLAD — OpenGL 3.3 loader (used only by the OpenGL backend, statically linked via
third_party/glad/) - C++17 stdlib — filesystem, fstream, vector, algorithms
- Build-time tool:
glslc(only required to regenerate.frag.spv)
Platform-Specific Code
Uses preprocessor defines (WINDOWS_BUILD, MACOS_BUILD, LINUX_BUILD) for:
getExecutableDirectory()- Windows API, mach-o dyld, or /proc/self/exegetResourcesDirectory()- Special macOS app bundle handling (Contents/Resources)- Linking flags - Windows uses static linking, macOS links OpenGL framework
Shader System
Authoring a shader (manual steps)
For each new shader, three handwritten files live in shaders/<name>/:
<name>.gl.glsl— OpenGL GLSL 3.30 (the original Shadertoy-style format described below).<name>.vk.glsl— Vulkan GLSL 4.50 with explicitlayout(set=3, binding=0) uniform ShadertoyUBO { float iTime; vec2 iResolution; } u;andlayout(set=2, binding=N) uniform sampler2Dfor any texture channel.<name>.frag.msl— Metal Shading Language. Entry point must be named<name>_fs. Uniforms come in via[[buffer(0)]]; samplers via[[texture(N)]] [[sampler(N)]].
After editing any .vk.glsl, rebuild SPIR-V: cmake --build build --target compile_shaders.
The MSL is not auto-generated — write it by hand following the same convention as aee_2026/source/core/rendering/sdl3gpu/msl/*.msl.h.
Shadertoy → Vulkan/Metal porting cheatsheet
iResolutionisvec2here (no.xy). Reconstruct vec3 manually if needed.- Replace
texture(iChannel0, ...)calls — no texture channels currently wired through the GPU backend (planned). - The shared vertex stage emits
vUValready in Shadertoy convention(0,0)bottom-left →(1,1)top-right; multiply byiResolutionto getfragCoord. - The Y axis is flipped in NDC inside the shared vertex shader so Vulkan/Metal render right-side-up like OpenGL.
Shader Format (GLSL 3.3 Core)
All shaders must follow this structure:
#version 330 core
precision highp float;
out vec4 FragColor;
in vec2 vUV; // Normalized [0,1] coordinates from vertex shader
uniform vec2 iResolution; // Window resolution in pixels
uniform float iTime; // Time since shader loaded (seconds)
// Shadertoy-style entry point
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
// fragCoord is in pixel coordinates
// Your shader code here
}
// Wrapper converts vUV to pixel coordinates
void main() {
vec2 fragCoordPixels = vUV * iResolution;
vec4 outColor;
mainImage(outColor, fragCoordPixels);
FragColor = outColor;
}
Adding New Shaders
- Create
.glslfile inshaders/directory (use.frag.glslconvention) - Follow required format above
- File automatically appears in runtime shader rotation (arrow keys to navigate)
- No code changes required - directory is scanned at startup
Shader Loading Pipeline
- Directory scan on startup (
scanShaderDirectory()) - Sort alphabetically
- Load fragment shader source from disk
- Compile vertex shader (fixed, embedded in main.cpp)
- Compile fragment shader with error logging
- Link program with error handling
- Update uniforms each frame (iResolution, iTime)
Supported Shadertoy Features
- ✅
iTime- Time uniform - ✅
iResolution- Window resolution (vec2, not vec3) - ✅
mainImage()function signature - ❌
iMouse- Not implemented - ❌
iChannel0-3- No texture channels (multi-pass not supported) - ❌
iFrame,iTimeDelta,iDate- Not implemented
Converting from Shadertoy
When porting Shadertoy shaders:
- Copy the
mainImage()function as-is - Add standard header (see format above)
- Add wrapper
main()function - Remove texture channel references (iChannel0-3)
- Change
iResolution.xytoiResolution(this project uses vec2)
Common Conversion Issues and Solutions
Based on experience converting complex shaders like ddla_light_tunnel:
iResolution vec3 vs vec2
- Shadertoy:
iResolutionisvec3(width, height, width/height) - This project:
iResolutionisvec2(width, height) - Solution: Create vec3 manually:
vec3 r = vec3(iResolution.xy, iResolution.x/iResolution.y); - Then use:
r.xyfor resolution,r.yfor height (as in original)
Uninitialized Variables
- Problem: Shadertoy code may have
vec3 rgb;without initialization - Shadertoy behavior: Likely initializes to
vec3(0.0)(black) - This project: Uninitialized variables contain undefined values (often causes black screen or wrong colors)
- Solution: Always initialize:
vec3 rgb = vec3(0.0); - Wrong approach: Don't use random noise unless shader explicitly uses iChannel texture for noise
Low mix() Factors Are Intentional
- Example:
rgb = mix(rgb, calculated_color, 0.01);means 1% new color, 99% existing - Don't change these factors - they create subtle effects intentionally
- If output is black: Problem is likely the base value (rgb), not the mix factor
iChannel Textures
- Shadertoy shows iChannel0-3 in UI, but shader may not use them
- If iChannel3 has RGB noise but code doesn't reference it: The noise is not actually used
- Check code for
texture(iChannelN, ...)calls - if none exist, ignore the iChannel setup - Procedural noise replacement: Only if shader explicitly samples the texture
Color Swapping Issues
- If colors are completely wrong: Don't randomly swap color variables
- First verify: Code matches original Shadertoy exactly (except required GLSL changes)
- Common mistake: Changing color applications that were correct in original
- Debug approach: Revert to exact original code, only modify for GLSL 3.3 compatibility
Division by Small Values - Color Overflow Artifacts
- Problem: Division by values near zero causes extreme values → color overflow artifacts (green points, strange colors)
- Example:
.01*vec4(6,2,1,0)/length(u*sin(iTime))can divide by ~0.0 when near center and sin≈0 - Symptoms: Bright white center that pulses to green, or random color artifacts in specific areas
- Root cause: Even with tonemap (tanh/clamp), extreme intermediate values cause precision issues or saturation
- Solution - Double Protection:
- Add epsilon to denominator:
max(denominator, 0.001)prevents division by exact zero - Clamp the result:
min(result, vec4(50.0))prevents extreme accumulation - Only clamp problematic terms: Don't clamp everything or scene becomes dim
- Add epsilon to denominator:
- Example fix:
// Original (causes overflow): o += .01*vec4(6,2,1,0)/length(u*sin(t+t+t)) + 1./s * length(u); // Fixed (prevents overflow while maintaining brightness): vec4 brightTerm = min(.01*vec4(6,2,1,0)/max(length(u*sin(t+t+t)), 0.001), vec4(50.0)); o += brightTerm + 1./s * length(u); - Key values: epsilon ~0.001 (not too small, not too large), clamp ~50.0 (allows brightness without explosion)
- Why this works in Shadertoy: WebGL/browsers may handle float overflow differently than native OpenGL drivers
Debugging Black/Wrong Output
- Check compilation errors first - shader must compile without errors
- Initialize all variables - especially vec3 colors
- Verify vec3 iResolution handling - create vec3 from vec2 if needed
- Don't modify mix factors - keep original values
- Compare with original code - ensure logic is identical
- Test progressive changes - add header, test; add wrapper, test; etc.
- Check for division by small values - if you see color artifacts (especially green), look for divisions that can approach zero
Build System Details
Version Numbering
Releases use build date format: shadertoy-YYYY-MM-DD-{platform} (auto-generated by Makefile)
Release Artifacts
- Windows:
.zipwithshadertoy.exe+SDL3.dll+ shaders - macOS:
.dmgwith app bundle containing embedded SDL3.framework (arm64 only) - Linux:
.tar.gzwith binary + shaders
Resource Bundling
- Shaders copied to release packages
- LICENSE and README.md included
- Platform-specific dependencies bundled (DLLs on Windows, frameworks on macOS)
Important Notes
Vertex Shader
The vertex shader is hardcoded in main.cpp and creates a fullscreen quad. It:
- Takes
vec2 aPos(location 0) in NDC space [-1, 1] - Outputs
vec2 vUVnormalized to [0, 1] - No transformation matrices needed
Shader Hot-Reloading
Currently not implemented. Shader changes require application restart. The architecture would support adding this via file watching.
Multi-Pass Rendering
Single-pass only. To add multi-pass (BufferA/B/C like Shadertoy):
- Create FBOs and textures per buffer
- Render buffers in dependency order
- Pass textures as
iChannel0-3uniforms - Use ping-pong for feedback loops
OpenGL Context
Created via SDL3 with core profile (no deprecated functions). Context version: 3.3 core.