From e5221b329f7497c6f9e2ff210ea717fb43e65eee Mon Sep 17 00:00:00 2001 From: Aiden Date: Wed, 6 May 2026 14:50:00 +1000 Subject: [PATCH] Added xyla shader --- README.md | 4 +- SHADER_CONTRACT.md | 60 +++++++++++ shaders/balatro-swirl/shader.json | 9 -- shaders/balatro-swirl/shader.slang | 4 +- shaders/xyla-exposure-chart/shader.json | 122 +++++++++++++++++++++++ shaders/xyla-exposure-chart/shader.slang | 78 +++++++++++++++ 6 files changed, 264 insertions(+), 13 deletions(-) create mode 100644 shaders/xyla-exposure-chart/shader.json create mode 100644 shaders/xyla-exposure-chart/shader.slang diff --git a/README.md b/README.md index e29d754..fecc31a 100644 --- a/README.md +++ b/README.md @@ -252,4 +252,6 @@ If neither variable is set, the workflow falls back to the repo-local defaults u - Continue source cleanup/refactoring. Pass 2 done - Support a separate sound shader `.slang` file in shader packages. (https://www.shadertoy.com/view/XsBXWt) - Add WebView2 -- move to MSDF, typography rasterisation \ No newline at end of file +- move to MSDF, typography rasterisation +- better shader search UI +- LUT applicator diff --git a/SHADER_CONTRACT.md b/SHADER_CONTRACT.md index 864e76b..dd4e012 100644 --- a/SHADER_CONTRACT.md +++ b/SHADER_CONTRACT.md @@ -57,6 +57,31 @@ float4 shadeVideo(ShaderContext context) With `autoReload` enabled in `config/runtime-host.json`, edits to shader source, manifests, and declared texture assets are picked up automatically. +## Guidance For Shaders + +When generating a new shader package, prefer matching the existing runtime contract over copying code verbatim from Shadertoy, GLSL sandbox sites, or WebGL demos. + +Important rules: + +- Generate a complete package: `shaders//shader.json` and `shaders//shader.slang`. +- Use `float4 shadeVideo(ShaderContext context)` unless the manifest explicitly sets a different `entryPoint`. +- Do not create `mainImage`, `main`, `fragColor`, `iResolution`, `iTime`, `iChannel0`, or a fragment shader attribute layout. The runtime wrapper provides the real fragment entry point. +- Replace Shadertoy `fragCoord` with `context.uv * context.outputResolution`. +- Replace `iResolution.xy` with `context.outputResolution`. +- Replace `iTime` with `context.time`. +- Replace `iFrame` with `context.frameCount`. +- Replace source-video `iChannel0` sampling with `sampleVideo(uv)` or `context.sourceColor`. +- Use Slang/HLSL names and syntax: `float2`, `float3`, `float4`, `float2x2`, `lerp`, `frac`, `saturate`, and `mul(matrix, vector)`. +- Do not use GLSL-only types/functions such as `vec2`, `vec3`, `vec4`, `mat2`, `mix`, `fract`, `mod`, `texture`, or `mainImage`. +- Keep parameter IDs, texture IDs, font IDs, and function entry points as valid shader identifiers: letters, numbers, and underscores only, starting with a letter or underscore. +- Add only controls that are actually used by the shader. +- Prefer a small number of clear controls with conservative defaults. +- Keep shaders deterministic unless randomness is an explicit feature. For stable pseudo-randomness, hash from `uv`, pixel coordinates, `frameCount`, or trigger values rather than using unavailable global state. +- If adapting third-party code, include attribution and source URL in the manifest description when the license allows adaptation. +- If the source license is unclear or incompatible, do not add the shader package. + +Before finishing, compile-check the shader through the runtime wrapper or launch the app and verify the shader appears without an error in the selector. + ## Manifest Fields `shader.json` is the runtime-facing description of the shader. @@ -460,6 +485,19 @@ See `shaders/temporal-ghost-trail/` and `shaders/temporal-low-fps/` for examples - Use `context.inputResolution` when sampling source video by input pixel size. - `sourceColor` and `sampleVideo` return RGBA values in normalized `0..1` range. - Prefer `saturate(color)` or explicit `clamp` before returning if your math can overshoot. +- For generated calibration charts, test patterns, gradients, and exposure ramps, state whether patch values are linear-light, display-referred gamma encoded, Rec.709 encoded, or intentionally artistic. +- For one-stop exposure patches, each patch should normally be `baseLevel * 2^patchIndex` before any display/tone encoding. +- For Rec.709 OETF encoding, use: + +```slang +float rec709Oetf(float linearLevel) +{ + float value = saturate(linearLevel); + if (value < 0.018) + return 4.5 * value; + return 1.099 * pow(value, 0.45) - 0.099; +} +``` Pixel-size example: @@ -468,6 +506,28 @@ float2 pixel = 1.0 / max(context.outputResolution, float2(1.0)); float4 right = sampleVideo(context.uv + float2(pixel.x, 0.0)); ``` +## Animation And Timing Notes + +- `context.time` is elapsed runtime time in seconds and is the default animation source for generative shaders. +- `context.frameCount` increments once per rendered output frame and is useful when an effect must be frame-locked. +- Avoid expensive CPU-like timing logic in the shader; animation should usually be a simple function of `context.time`, `context.frameCount`, trigger uniforms, or parameters. +- If a shader appears to judder only while animated, first test whether freezing its time removes the issue. That usually separates animation cadence issues from rendering or transfer issues. +- Do not add custom timer uniforms to the wrapper. Use the fields already in `ShaderContext`. + +## Performance Notes + +The app has to meet a fixed video frame cadence, so avoid shader code that only looks good in unconstrained browser demos. + +Guidelines: + +- Keep loops bounded with compile-time constants where possible. +- Avoid very high per-pixel raymarch counts by default. If a heavy loop is needed, expose a quality/steps control with a safe default. +- Prefer early exits only when they are simple; highly divergent branches can be expensive across a full frame. +- Avoid repeated texture sampling in large loops unless the effect really needs it. +- Use `context.outputResolution` carefully. A 1080p frame is over 2 million fragments; a tiny extra loop can become expensive. +- The UI render time may measure CPU command submission rather than true GPU execution time, so visual frame issues can still be GPU-related even when reported render time is small. +- Do not write debug files, allocate resources, or assume CPU-side work can happen from `shader.slang`. Shader code is GPU-only. + ## Reload And Generated Files When a shader compiles, the runtime writes generated files under `runtime/shader_cache/`: diff --git a/shaders/balatro-swirl/shader.json b/shaders/balatro-swirl/shader.json index 789eec8..c923e1c 100644 --- a/shaders/balatro-swirl/shader.json +++ b/shaders/balatro-swirl/shader.json @@ -41,15 +41,6 @@ "max": 3.0, "step": 0.01 }, - { - "id": "pixelFilter", - "label": "Pixel Filter", - "type": "float", - "default": 745.0, - "min": 120.0, - "max": 1600.0, - "step": 1.0 - }, { "id": "contrast", "label": "Contrast", diff --git a/shaders/balatro-swirl/shader.slang b/shaders/balatro-swirl/shader.slang index ff50938..b99cbfd 100644 --- a/shaders/balatro-swirl/shader.slang +++ b/shaders/balatro-swirl/shader.slang @@ -1,10 +1,8 @@ float4 balatroSwirl(float2 screenSize, float2 screenCoords, float time) { const float pi = 3.14159265359; - float safePixelFilter = max(pixelFilter, 1.0); float safeScreenLength = max(length(screenSize), 1.0); - float pixelSize = safeScreenLength / safePixelFilter; - float2 uv = (floor(screenCoords * (1.0 / pixelSize)) * pixelSize - 0.5 * screenSize) / safeScreenLength - offset; + float2 uv = (screenCoords - 0.5 * screenSize) / safeScreenLength - offset; float uvLength = length(uv); float speed = spinRotation * spinEase * 0.2; diff --git a/shaders/xyla-exposure-chart/shader.json b/shaders/xyla-exposure-chart/shader.json new file mode 100644 index 0000000..615718a --- /dev/null +++ b/shaders/xyla-exposure-chart/shader.json @@ -0,0 +1,122 @@ +{ + "id": "xyla-exposure-chart", + "name": "XYLA Exposure Chart", + "description": "Procedural grayscale exposure chart inspired by XYLA-style dynamic range charts, with each patch one stop brighter than the previous.", + "category": "Calibration", + "entryPoint": "shadeVideo", + "parameters": [ + { + "id": "patchCount", + "label": "Patch Count", + "type": "float", + "default": 15.0, + "min": 2.0, + "max": 21.0, + "step": 1.0 + }, + { + "id": "baseLevel", + "label": "Base Level", + "type": "float", + "default": 0.00006103515625, + "min": 0.000001, + "max": 0.01, + "step": 0.000001 + }, + { + "id": "peakLevel", + "label": "Peak Level", + "type": "float", + "default": 1.0, + "min": 0.01, + "max": 1.0, + "step": 0.001 + }, + { + "id": "gammaEncode", + "label": "Display Gamma", + "type": "float", + "default": 1.0, + "min": 1.0, + "max": 2.6, + "step": 0.01 + }, + { + "id": "toneCurve", + "label": "Tone Curve", + "type": "enum", + "default": "rec709", + "options": [ + { + "value": "linear", + "label": "Linear" + }, + { + "value": "gamma", + "label": "Display Gamma" + }, + { + "value": "rec709", + "label": "Rec.709" + } + ] + }, + { + "id": "chartScale", + "label": "Chart Scale", + "type": "float", + "default": 0.86, + "min": 0.25, + "max": 1.0, + "step": 0.01 + }, + { + "id": "gapSize", + "label": "Gap Size", + "type": "float", + "default": 0.18, + "min": 0.0, + "max": 0.45, + "step": 0.01 + }, + { + "id": "vertical", + "label": "Vertical", + "type": "bool", + "default": false + }, + { + "id": "reverseOrder", + "label": "Reverse Order", + "type": "bool", + "default": false + }, + { + "id": "backgroundLevel", + "label": "Background", + "type": "float", + "default": 0.0, + "min": 0.0, + "max": 0.2, + "step": 0.001 + }, + { + "id": "borderLevel", + "label": "Border", + "type": "float", + "default": 0.08, + "min": 0.0, + "max": 1.0, + "step": 0.001 + }, + { + "id": "sourceMix", + "label": "Source Mix", + "type": "float", + "default": 0.0, + "min": 0.0, + "max": 1.0, + "step": 0.01 + } + ] +} diff --git a/shaders/xyla-exposure-chart/shader.slang b/shaders/xyla-exposure-chart/shader.slang new file mode 100644 index 0000000..bfcfaf1 --- /dev/null +++ b/shaders/xyla-exposure-chart/shader.slang @@ -0,0 +1,78 @@ +float boxMask(float2 point, float2 halfSize, float feather) +{ + float2 distanceToEdge = abs(point) - halfSize; + float outsideDistance = length(max(distanceToEdge, float2(0.0, 0.0))); + float insideDistance = min(max(distanceToEdge.x, distanceToEdge.y), 0.0); + float signedDistance = outsideDistance + insideDistance; + return 1.0 - smoothstep(0.0, max(feather, 0.00001), signedDistance); +} + +float rec709Oetf(float linearLevel) +{ + float value = saturate(linearLevel); + if (value < 0.018) + return 4.5 * value; + return 1.099 * pow(value, 0.45) - 0.099; +} + +float applyToneCurve(float linearLevel) +{ + float value = saturate(linearLevel); + if (toneCurve == 1) + { + float safeGamma = max(gammaEncode, 0.001); + return pow(value, 1.0 / safeGamma); + } + if (toneCurve == 2) + return rec709Oetf(value); + return value; +} + +float patchBrightness(int patchIndex, int count) +{ + int clampedIndex = clamp(patchIndex, 0, max(count - 1, 0)); + float linearLevel = baseLevel * exp2(float(clampedIndex)); + linearLevel = min(linearLevel, peakLevel); + return applyToneCurve(linearLevel); +} + +float4 shadeVideo(ShaderContext context) +{ + float2 resolution = max(context.outputResolution, float2(1.0, 1.0)); + float2 uv = saturate(context.uv); + float2 centered = uv - 0.5; + float feather = 1.5 / min(resolution.x, resolution.y); + + int count = int(clamp(round(patchCount), 2.0, 21.0)); + float2 chartHalfSize = vertical + ? float2(0.18, 0.46) * chartScale + : float2(0.46, 0.18) * chartScale; + float chartMask = boxMask(centered, chartHalfSize, feather); + float borderMask = chartMask - boxMask(centered, max(chartHalfSize - float2(feather * 3.0, feather * 3.0), float2(0.0, 0.0)), feather); + + float axis = vertical ? centered.y : centered.x; + float crossAxis = vertical ? centered.x : centered.y; + float axisHalfSize = vertical ? chartHalfSize.y : chartHalfSize.x; + float crossHalfSize = vertical ? chartHalfSize.x : chartHalfSize.y; + float normalizedAxis = (axis + axisHalfSize) / max(axisHalfSize * 2.0, 0.0001); + float patchPosition = clamp(normalizedAxis, 0.0, 0.999999) * float(count); + int patchIndex = int(floor(patchPosition)); + if (reverseOrder) + patchIndex = count - 1 - patchIndex; + + float patchSlotCenter = (floor(patchPosition) + 0.5) / float(count); + float localAxis = abs(normalizedAxis - patchSlotCenter) * float(count) * 2.0; + float safeGapSize = saturate(gapSize); + float axisMask = 1.0 - smoothstep(1.0 - safeGapSize, 1.0 - safeGapSize + feather * float(count) * 2.0, localAxis); + float crossMask = 1.0 - smoothstep(crossHalfSize, crossHalfSize + feather, abs(crossAxis)); + float insideAxis = step(0.0, normalizedAxis) * step(normalizedAxis, 1.0); + float patchMask = axisMask * crossMask * insideAxis; + + float level = patchBrightness(patchIndex, count); + float chartBackground = saturate(backgroundLevel) * chartMask; + float border = saturate(borderLevel) * borderMask; + float grayscale = max(max(chartBackground, border), level * patchMask); + + float4 chartColor = float4(grayscale, grayscale, grayscale, 1.0); + return saturate(lerp(chartColor, context.sourceColor, sourceMix)); +}