The Lux CPU-side shader debugger lets you step through shader code, inspect variables, and detect NaN/Inf issues — all without a GPU. It works by interpreting the shader AST directly in Python, with the same math semantics as SPIR-V.
# Interactive debugging (gdb-style REPL)
python -m luxc examples/debug_playground.lux --debug-run --stage fragment
# Batch mode — detect NaN/Inf issues
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --batch --check-nan
# Batch mode — dump all variable values
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --batch --dump-vars
# Batch mode — break at specific lines and dump state
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --batch --break 100 --dump-at-break
# Custom inputs — override default values
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --input examples/debug_playground_inputs.json --batch --dump-vars| Flag | Description |
|---|---|
--debug-run |
Launch the CPU debugger instead of compiling |
--stage <name> |
Which stage to debug: fragment, vertex, compute, etc. (default: fragment) |
--batch |
Machine-readable JSON output mode (no interactive REPL) |
--dump-vars |
Record every variable assignment (batch mode) |
--check-nan |
Flag NaN/Inf values with source location (batch mode) |
--break <line> |
Set a breakpoint at a source line (repeatable) |
--dump-at-break |
Dump all visible variables when a breakpoint is hit |
--input <file.json> |
Load custom input values from JSON |
--pixel X,Y |
Debug a specific pixel (auto-computes uv, position, normal) |
--resolution WxH |
Screen resolution for --pixel (default: 1920x1080) |
--set VAR=VALUE |
Override a single input (repeatable, e.g. --set roughness=0.1) |
--export-inputs <file.json> |
Export default input values to JSON for editing |
--debug-print |
Preserve debug_print/assert without full -g debug mode |
--assert-kill |
Demote fragment invocation on assert failure (OpDemoteToHelperInvocation) |
--rich-debug |
Emit NonSemantic.Shader.DebugInfo.100 for RenderDoc step-through (implies -g) |
When running without --batch, you get an interactive REPL:
| Command | Short | Description |
|---|---|---|
start |
Begin execution, stop at first statement | |
run |
r |
Run to completion (stops only at breakpoints) |
step |
s |
Step into the next statement — shows new/changed variables |
next |
n |
Step over (don't enter function calls) |
continue |
c |
Continue to next breakpoint |
finish |
f |
Run until current function returns |
| Command | Short | Description |
|---|---|---|
break <line> |
b <line> |
Set a breakpoint at a source line |
break <line> if <expr> |
Set a conditional breakpoint (stops only when <expr> is truthy) |
|
break-nan |
bn |
Toggle break-on-NaN (stop when NaN/Inf is first produced) |
delete <id> |
d <id> |
Remove a breakpoint |
breakpoints |
info |
List all breakpoints with conditions and hit counts |
| Command | Short | Description |
|---|---|---|
print <var> |
p <var> |
Print a variable's current value |
eval <expr> |
e <expr> |
Evaluate any Lux expression in current scope |
locals |
l |
Show all variables in current scope |
list |
Show full shader with > cursor and * breakpoint markers |
|
watch <expr> |
w <expr> |
Add a watch expression (evaluated at each stop) |
source [line] |
src [line] |
Show source code around current/specified line (5 lines) |
output |
Show the current output variable | |
backtrace |
bt |
Show current position |
| Command | Short | Description |
|---|---|---|
reverse-step |
rs |
Step backwards one statement (restores previous state) |
reverse-continue |
rc |
Run backwards until hitting a breakpoint |
goto <step> |
g <step> |
Jump to a specific step number in execution history |
history [N] |
hist [N] |
Show the last N execution steps (default: 10) |
| Command | Short | Description |
|---|---|---|
quit |
q |
Exit the debugger |
help |
h |
Show all commands |
When stepping, variable changes are shown automatically:
lux-debug> step
+ albedo = vec3(0.800, 0.800, 0.800) (vec3) <- new variable
Stopped at line 88
> 88 | let n: vec3 = normalize(world_normal);
$ python -m luxc examples/debug_playground.lux --debug-run --stage fragment
lux-debug> start
Stopped at line 87
> 87 | let albedo: vec3 = sample(albedo_tex, uv).rgb;
lux-debug> step
Stopped at line 88
> 88 | let n: vec3 = normalize(world_normal);
lux-debug> step
Stopped at line 91
lux-debug> print albedo
albedo = vec3(0.800, 0.800, 0.800) (vec3)
lux-debug> print n
n = vec3(0.000, 0.000, 1.000) (vec3)
lux-debug> locals
albedo = vec3(0.800, 0.800, 0.800)
n = vec3(0.000, 0.000, 1.000)
roughness = 0.500 (scalar)
metallic = 0.000 (scalar)
...
lux-debug> break 113
Breakpoint 1 at line 113
lux-debug> continue
Hit breakpoint 1 at line 113
lux-debug> print d
d = 0.141471 (scalar)
lux-debug> print g
g = 0.640000 (scalar)
lux-debug> continue
Output: vec4(0.403, 0.381, 0.345, 1.000)
Workflow tips:
- Use
startorstepto begin stepping from line 1 - Use
break <line> + runto jump directly to a specific location - Use
stepinside functions to follow the call,nextto skip over them - Use
finishto run until the current function returns
Breakpoints can include a condition expression. The debugger only stops when the condition evaluates to true.
lux-debug> break 100 if roughness < 0.1
Breakpoint 1 at line 100 (condition: roughness < 0.1)
lux-debug> break 55 if dot(n, l) > 0.9
Breakpoint 2 at line 55 (condition: dot(n, l) > 0.9)
The condition is parsed using the full Lux expression parser, so it supports all arithmetic, comparisons, function calls, field/swizzle access, and constructors. If the condition fails to parse or evaluate, the breakpoint is treated as unconditional (it always triggers).
The hit_count field on each Breakpoint tracks how many times the condition was met and the breakpoint fired.
Programmatic:
dbg.add_breakpoint(100, condition="roughness < 0.1")The watch command evaluates expressions in the current interpreter environment at every stop.
lux-debug> watch x + y
Watch 1: x + y
lux-debug> watch dot(n, l)
Watch 2: dot(n, l)
lux-debug> watch length(world_position)
Watch 3: length(world_position)
When stepping, watched expressions are re-evaluated and the debugger highlights any that changed since the last stop:
lux-debug> step
[watch 1] x + y = 7.000 (changed)
[watch 2] dot(n, l) = 0.333
Programmatic:
w = dbg.add_watch("x + y")
results = dbg.evaluate_watches()
# Returns: [(WatchEntry, LuxValue | None, changed: bool), ...]Evaluate any Lux expression in the current scope without modifying state:
lux-debug> eval dot(n, l) * 2.0
0.666 (scalar)
lux-debug> e vec3(roughness, metallic, ao)
vec3(0.500, 0.000, 1.000)
lux-debug> e normalize(world_position - camera_pos)
vec3(0.577, 0.577, 0.577)
Expressions are parsed by the full Lux parser (luxc.debug.expr_parser.parse_debug_expr) and evaluated in the interpreter's current environment, giving access to all variables, builtins, and user-defined functions.
Automatically stop execution the moment a NaN or Inf value is produced, rather than discovering it post-mortem.
Interactive:
lux-debug> break-nan
NaN break enabled — will stop when NaN/Inf is produced
lux-debug> run
NaN detected at line 55: dir = vec3(NaN, NaN, NaN)
Stopped at line 56
Short form: bn
When break-on-NaN triggers, the debugger switches to step mode so you can inspect the state immediately after the offending operation.
The debugger records execution history, allowing you to step backwards through previously executed statements and inspect past state.
Example session:
lux-debug> step
Stopped at line 100
lux-debug> step
Stopped at line 101
lux-debug> reverse-step
[time-travel] Restored to step 5, line 100
lux-debug> print roughness
roughness = 0.500 (scalar)
lux-debug> history 5
Step 1: line 87
Step 2: line 88
Step 3: line 91
Step 4: line 95
Step 5: line 100 <-- current
lux-debug> goto 2
[time-travel] Restored to step 2, line 88
Each ExecutionSnapshot contains:
step_index: monotonically increasing step counterline: source line numberenv_snapshot: deep copy of all variables at that moment
History is capped at 10,000 steps by default to limit memory usage.
Programmatic:
snap = dbg.reverse_step() # ExecutionSnapshot | None
snap = dbg.reverse_continue() # Run backwards to previous breakpoint
snap = dbg.goto_step(5) # Jump to step 5
history = dbg.time_travel.get_recent(10) # Last 10 snapshotsThe --batch flag outputs a JSON object to stdout:
{
"status": "completed",
"statements_executed": 53,
"nan_detected": true,
"output": {
"type": "vec4",
"value": [0.403, 0.381, 0.345, 1.0]
},
"nan_events": [
{
"line": 55,
"variable": "dir",
"operation": "let",
"value": {"type": "vec3", "value": ["NaN", "NaN", "NaN"]}
}
],
"variable_trace": [
{
"line": 84,
"name": "albedo",
"type": "vec3",
"value": {"type": "vec3", "value": [0.8, 0.8, 0.8]}
}
],
"break_dumps": [
{
"line": 100,
"variables": {
"albedo": {"type": "vec3", "value": [0.8, 0.8, 0.8]},
"roughness": {"type": "scalar", "value": 0.5}
}
}
]
}| Field | Present When | Description |
|---|---|---|
status |
Always | "completed" or "error" |
statements_executed |
Always | Number of statements the interpreter ran |
nan_detected |
--check-nan |
Whether any NaN/Inf was found |
output |
Always | The first output variable (e.g., color) |
nan_events |
--check-nan and NaN found |
Array of NaN events with source line |
variable_trace |
--dump-vars |
Array of every variable assignment |
break_dumps |
--dump-at-break |
Scope snapshots at each breakpoint hit |
debug_prints |
If shader has debug_print() |
Printf-style output |
assert_failures |
If asserts fail | Failed assertion messages |
Use --pixel X,Y to debug the shader at a specific screen position:
# Center of a 1920x1080 screen (default resolution)
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --pixel 960,540
# Top-left corner of an 800x600 screen
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --pixel 0,0 --resolution 800x600
# Edge pixel — steep viewing angle, good for testing Fresnel
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --pixel 50,300 --resolution 800x600 --batch --check-nan--pixel X,Y auto-computes from the pixel coordinates:
| Variable | Derivation |
|---|---|
uv |
((X+0.5)/W, (Y+0.5)/H) — pixel center in [0,1] |
world_position |
Mapped to [-1,1] unit quad |
normal / world_normal |
Hemisphere: center faces camera, edges face sideways |
Override individual variables without creating a JSON file:
# Mirror-smooth metal
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --set roughness=0.01 --set metallic=1.0 --batch
# Custom normal direction
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --set "normal=1,0,0" --batch --check-nan
# Combine pixel + material overrides
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --pixel 100,200 --set roughness=0.0 --batch --check-nanSupported value formats for --set:
- Scalar:
--set roughness=0.5 - Vector:
--set "normal=0,1,0"(comma-separated, no spaces) - Boolean:
--set flag=true
Multiple --set flags stack, and they merge with --input JSON if both are provided.
The debugger provides sensible defaults for common shader inputs:
| Input Name | Default Value |
|---|---|
position, world_position |
vec3(0, 0, 0) |
normal, world_normal |
vec3(0, 0, 1) — pointing toward camera |
uv, texcoord |
vec2(0.5, 0.5) — center of texture |
roughness |
0.5 |
metallic |
0.0 — dielectric |
exposure |
1.0 |
ao, occlusion |
1.0 — no occlusion |
All mat4 uniforms |
Identity matrix |
All sampler2d samples |
vec4(0.8, 0.8, 0.8, 1.0) — neutral grey |
Export defaults, edit, then replay:
# Export
python -m luxc examples/debug_playground.lux --debug-run --export-inputs my_inputs.json
# Edit my_inputs.json to change values...
# Replay with custom inputs
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --input my_inputs.json --batch --dump-varsInput JSON format:
{
"world_normal": [0.0, 1.0, 0.0],
"world_position": [1.0, 0.0, -2.0],
"uv": [0.5, 0.5],
"roughness": 0.35,
"metallic": 0.0,
"exposure": 1.5
}For bindless shaders, the input JSON can also specify texture paths and material overrides:
{
"textures": [
"assets/textures/albedo.png",
"assets/textures/normal.png",
"assets/textures/roughness.png"
],
"materials": [
{
"baseColorFactor": [1.0, 0.0, 0.0, 1.0],
"metallicFactor": 1.0,
"roughnessFactor": 0.01,
"base_color_tex_index": 0,
"normal_tex_index": 1
}
],
"world_normal": [0.0, 1.0, 0.0],
"roughness": 0.35
}Supported value formats:
- Scalar:
0.5or{"type": "scalar", "value": 0.5} - Vector:
[1.0, 2.0, 3.0]or{"type": "vec3", "value": [1.0, 2.0, 3.0]} - Matrix:
[[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]] - Integer:
42or{"type": "int", "value": 42} - Boolean:
trueor{"type": "bool", "value": true}
The debugger can load actual texture files from disk for sample() calls instead of returning constant grey.
Requirements: pip install Pillow numpy
Via JSON input:
{
"textures": ["textures/albedo.png", "textures/normal.png"]
}Via Python API:
from luxc.debug.values import load_image, LuxImage
img = load_image("textures/albedo.png", wrap="repeat")
# img.sample_bilinear(u, v) returns vec4 in [0,1]Texture sampling features:
- Bilinear-filtered lookups (4-tap interpolation)
- Wrap modes:
repeat(default) andclamp - Supported formats: PNG, JPG, TGA, BMP (anything Pillow can open)
sample_bindless()indexes into texture arrays for bindless shaders
Run the shader across a grid of pixels and produce a PPM image visualizing output colors, NaN locations, performance hotspots, or variable magnitudes.
from luxc.debug.heatmap import run_pixel_grid, write_ppm
source = open("examples/debug_playground.lux").read()
# Output color visualization
result = run_pixel_grid(source, "fragment", grid_size=(64, 64), mode="output")
write_ppm("color.ppm", result)
# NaN heatmap — red where NaN occurs, green where clean
result = run_pixel_grid(source, "fragment", grid_size=(32, 32), mode="nan", check_nan=True)
write_ppm("nan.ppm", result)
# Performance heatmap — blue (few statements) to red (many)
result = run_pixel_grid(source, "fragment", grid_size=(16, 16), mode="perf")
write_ppm("perf.ppm", result)
# Variable magnitude heatmap
result = run_pixel_grid(source, "fragment", grid_size=(32, 32),
mode="var:roughness", var_name="roughness")
write_ppm("rough.ppm", result)Modes:
| Mode | Visualization |
|---|---|
output |
Shader output color (vec4 RGBA mapped to RGB) |
nan |
Red = NaN present, Green = clean |
perf |
Blue-to-red heatmap by statement count |
var:<name> |
Blue-to-red heatmap by variable magnitude |
Output format: P6 binary PPM (viewable in most image viewers, convertible with ImageMagick).
Inspecting results:
for pixel in result.pixels:
if pixel.nan_count > 0:
print(f"NaN at ({pixel.x},{pixel.y}) uv=({pixel.u:.2f},{pixel.v:.2f})")The debugger provides automatic defaults for bindless material and texture array patterns, matching the BindlessMaterialData struct layout.
Automatic material defaults:
When a shader declares a storage buffer with BindlessMaterialData structs, the debugger automatically provides a default material:
| Field | Default |
|---|---|
baseColorFactor |
vec4(0.8, 0.8, 0.8, 1.0) |
metallicFactor |
0.0 (dielectric) |
roughnessFactor |
0.5 |
ior |
1.5 |
emissiveFactor |
vec3(0.0) |
All *_tex_index fields |
-1 (no texture bound) |
Texture array defaults:
Bindless texture arrays are populated with neutral grey 1x1 images by default.
List indexing for SSBOs:
The interpreter supports list indexing for storage buffer arrays:
lux-debug> print materials[0].roughnessFactor
0.500 (scalar)
lux-debug> print materials[1].baseColorFactor
vec4(0.800, 0.800, 0.800, 1.000)
Programmatic:
from luxc.debug.io import build_default_material
mat = build_default_material()
# Returns LuxStruct("BindlessMaterialData", {...})python -m luxc shader.lux --debug-run --stage fragment --batch --check-nanCommon NaN sources in shaders:
normalize(vec3(0, 0, 0))— normalizing a zero vector0.0 / 0.0— zero divided by zerosqrt(-x)— square root of negativepow(0.0, -1.0)— zero to negative poweracos(1.0001)— domain error in trig
The nan_events array tells you exactly which variable and which line.
Interactive NaN hunting with break-on-NaN:
lux-debug> break-nan
lux-debug> run
NaN detected at line 55: dir = vec3(NaN, NaN, NaN)
Stopped at line 56
lux-debug> eval diff
vec3(0.000, 0.000, 0.000)
Override inputs to simulate specific screen positions or geometry:
{"world_normal": [1.0, 0.0, 0.0], "roughness": 0.01, "metallic": 1.0}This simulates a highly metallic, mirror-smooth surface with a side-facing normal — a case that often triggers specular edge issues.
python -m luxc examples/debug_playground.lux --debug-run --stage fragment --batch --dump-vars --check-nanThe variable_trace shows every intermediate value in execution order, letting you follow the math step by step:
albedo = vec3(0.800, 0.800, 0.800) # from texture
n = vec3(0.000, 0.000, 1.000) # surface normal
n_dot_l = 0.333 # Lambert factor
d = 0.141 # GGX NDF
g = 0.640 # Smith geometry
f = vec3(0.040, 0.040, 0.040) # Fresnel
specular = vec3(0.003, 0.003, 0.003) # BRDF specular
diffuse = vec3(0.244, 0.244, 0.244) # Lambert diffuse
direct = vec3(0.247, 0.231, 0.206) # Combined with light color
hdr_color = vec3(0.271, 0.255, 0.230) # + rim + ambient
ldr_color = vec3(0.403, 0.381, 0.345) # After ACES tonemap
Set breakpoints at key stages and dump the full variable state:
python -m luxc examples/debug_playground.lux --debug-run --stage fragment \
--batch --break 100 --break 113 --break 137 --dump-at-breakLine 100: after material setup (check f0, diffuse_color) Line 113: after BRDF denominator (check specular term is finite) Line 137: before tonemap (check HDR range is reasonable)
Use time-travel to compare variable states at different execution points without restarting:
lux-debug> break 100
lux-debug> run
Hit breakpoint at line 100
lux-debug> print specular
vec3(0.003, 0.003, 0.003)
lux-debug> continue
Output: vec4(0.403, 0.381, 0.345, 1.000)
lux-debug> reverse-continue
[time-travel] Restored to step 23, line 100
lux-debug> print specular
vec3(0.003, 0.003, 0.003)
lux-debug> goto 1
[time-travel] Restored to step 1, line 87
The debugger supports the full Lux language except GPU-specific intrinsics:
- All arithmetic, comparison, and logical operators
letbindings,if/else,for,while,break,continue- User-defined functions with arguments and return values
- Vector/matrix constructors:
vec3(1.0),mat4(1.0),vec4(v3, 1.0) - Swizzle access:
.xyz,.rgb,.xy, single component.x - Index access:
v[0],m[col][row],list[i](for SSBO arrays) - Struct field access
- 47 math builtins matching GLSL.std.450 semantics
debug_print()andassert()statements- Module-level constants
- Texture
sample()with real image data (or mock grey fallback) sample_bindless()andsample_bindless_lod()for bindless texture arrays
- Ray tracing intrinsics (
traceRay, etc.) - Compute shader atomics and barriers
dFdx/dFdyscreen-space derivatives
luxc/debug/
values.py — Value types (LuxScalar, LuxVec, LuxMat, LuxInt, LuxBool, LuxStruct, LuxImage)
environment.py — Scoped variable storage with parent chain lookup
builtins.py — 47+ math builtins + texture-aware sample/sample_bindless
interpreter.py — Tree-walking AST evaluator (exec_stmt, eval_expr, list indexing)
debugger.py — Breakpoints (conditional), stepping, watches, break-on-NaN, time-travel
expr_parser.py — Debug expression parser (reuses full Lux parser)
io.py — Input loading (JSON, semantic defaults, textures, materials)
heatmap.py — Multi-pixel grid runner + PPM output
cli.py — Interactive REPL and batch mode orchestration
The debugger reuses the compiler's parser and type checker but skips codegen entirely. It runs the AST directly in Python, making it fast to iterate and independent of GPU drivers.
Run the automated P18.2 test suite:
# All checks (58 tests)
python tests/manual_test_p18_2.py --all
# Specific section
python tests/manual_test_p18_2.py --section a1 # Conditional breakpoints
python tests/manual_test_p18_2.py --section b # Time-travel
python tests/manual_test_p18_2.py --section c # Heatmap
python tests/manual_test_p18_2.py --section d # Real textures
python tests/manual_test_p18_2.py --section cli # CLI integration
# Interactive testing guide
python tests/manual_test_p18_2.py --guide