From 6502344d0ae3a1e2ce694e5ce3ab8ae4352d2d6c Mon Sep 17 00:00:00 2001 From: Aiden Date: Wed, 6 May 2026 14:01:23 +1000 Subject: [PATCH] Added trigger --- README.md | 5 +- SHADER_CONTRACT.md | 22 +++++++ .../gl/GlobalParamsBuffer.cpp | 4 ++ .../runtime/RuntimeHost.cpp | 32 ++++++++++ .../runtime/RuntimeParameterUtils.cpp | 11 ++++ .../shader/ShaderCompiler.cpp | 7 +++ .../shader/ShaderPackageRegistry.cpp | 5 ++ .../shader/ShaderTypes.h | 3 +- docs/OSC_CONTROL.md | 6 ++ docs/openapi.yaml | 2 +- shaders/trigger-ripple/shader.json | 59 +++++++++++++++++++ shaders/trigger-ripple/shader.slang | 34 +++++++++++ tests/RuntimeParameterUtilsTests.cpp | 23 ++++++++ tests/ShaderPackageRegistryTests.cpp | 6 +- ui/src/components/ParameterField.jsx | 21 ++++++- ui/src/components/ParameterValueDisplay.jsx | 3 + 16 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 shaders/trigger-ripple/shader.json create mode 100644 shaders/trigger-ripple/shader.slang diff --git a/README.md b/README.md index 8cd003a..da14a24 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ If neither variable is set, the workflow falls back to the repo-local defaults u - Find a better UI library for react. - Logs. - Continue source cleanup/refactoring. Pass 1 done -- Support a separate sound shader `.slang` file in shader packages. +- 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 +- abritary function/triggers for the shader \ No newline at end of file diff --git a/SHADER_CONTRACT.md b/SHADER_CONTRACT.md index 25ff680..864e76b 100644 --- a/SHADER_CONTRACT.md +++ b/SHADER_CONTRACT.md @@ -187,6 +187,7 @@ Supported types: | `bool` | `bool` | `true` or `false` | | `enum` | `int` | selected option index | | `text` | generated texture/helper | string | +| `trigger` | `int `, `float Time` | pulse/count | Float example: @@ -314,6 +315,26 @@ float4 premultipliedText = drawTitleText(textUv, float4(1.0, 1.0, 1.0, 1.0)); Text is currently limited to printable ASCII. `maxLength` defaults to `64` and is clamped to `1..256`. The optional `font` field references a packaged font declared in `fonts`; if no font is specified, the runtime uses its fallback sans-serif renderer. +Trigger example: + +```json +{ + "id": "flash", + "label": "Flash", + "type": "trigger" +} +``` + +A trigger appears as a button in the control UI. Pressing it increments the shader-visible integer `flash` and records the runtime time in `flashTime`: + +```slang +float age = context.time - flashTime; +float intensity = flash > 0 ? exp(-age * 5.0) : 0.0; +color.rgb += intensity; +``` + +Triggers are useful for one-shot shader reactions such as flashes, ripples, cuts, or randomized looks. They do not execute arbitrary CPU code; they only update uniforms consumed by the shader. + Parameter validation: - Float values are clamped to `min`/`max` if provided. @@ -321,6 +342,7 @@ Parameter validation: - `color` must have exactly 4 numbers. - Enum defaults must match one of the declared option values. - Text defaults must be strings. Non-printable characters are dropped and values are clamped to `maxLength`. +- Trigger values are incremented by the host when triggered. The shader sees the trigger count and last trigger time. - Non-finite numeric values are rejected. ## Texture Assets diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/GlobalParamsBuffer.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/GlobalParamsBuffer.cpp index 776c95a..c0634f7 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/GlobalParamsBuffer.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/GlobalParamsBuffer.cpp @@ -75,6 +75,10 @@ bool GlobalParamsBuffer::Update(const RuntimeRenderState& state, unsigned availa } case ShaderParameterType::Text: break; + case ShaderParameterType::Trigger: + AppendStd140Int(buffer, value.numberValues.empty() ? 0 : static_cast(value.numberValues[0])); + AppendStd140Float(buffer, value.numberValues.size() > 1 ? static_cast(value.numberValues[1]) : -1000000.0f); + break; } } diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.cpp index 79cdeee..ada5948 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeHost.cpp @@ -136,6 +136,7 @@ std::string ShaderParameterTypeToString(ShaderParameterType type) case ShaderParameterType::Boolean: return "bool"; case ShaderParameterType::Enum: return "enum"; case ShaderParameterType::Text: return "text"; + case ShaderParameterType::Trigger: return "trigger"; } return "unknown"; } @@ -187,6 +188,11 @@ bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType& type = ShaderParameterType::Text; return true; } + if (typeName == "trigger") + { + type = ShaderParameterType::Trigger; + return true; + } return false; } @@ -1011,6 +1017,15 @@ bool RuntimeHost::UpdateLayerParameter(const std::string& layerId, const std::st return false; } + if (parameterIt->type == ShaderParameterType::Trigger) + { + ShaderParameterValue& value = layer->parameterValues[parameterId]; + const double previousCount = value.numberValues.empty() ? 0.0 : value.numberValues[0]; + const double triggerTime = std::chrono::duration_cast>(std::chrono::steady_clock::now() - mStartTime).count(); + value.numberValues = { previousCount + 1.0, triggerTime }; + return true; + } + ShaderParameterValue normalized; if (!NormalizeAndValidateValue(*parameterIt, newValue, normalized, error)) return false; @@ -1057,6 +1072,15 @@ bool RuntimeHost::UpdateLayerParameterByControlKey(const std::string& layerKey, return false; } + if (parameterIt->type == ShaderParameterType::Trigger) + { + ShaderParameterValue& value = matchedLayer->parameterValues[parameterIt->id]; + const double previousCount = value.numberValues.empty() ? 0.0 : value.numberValues[0]; + const double triggerTime = std::chrono::duration_cast>(std::chrono::steady_clock::now() - mStartTime).count(); + value.numberValues = { previousCount + 1.0, triggerTime }; + return true; + } + ShaderParameterValue normalized; if (!NormalizeAndValidateValue(*parameterIt, newValue, normalized, error)) return false; @@ -1714,6 +1738,12 @@ void RuntimeHost::EnsureLayerDefaultsLocked(LayerPersistentState& layerState, co } break; } + case ShaderParameterType::Trigger: + if (valueIt->second.numberValues.empty()) + valueJson = JsonValue(0.0); + else + valueJson = JsonValue(std::max(0.0, std::floor(valueIt->second.numberValues.front()))); + break; } if (!shouldNormalize) @@ -2103,6 +2133,8 @@ JsonValue RuntimeHost::SerializeParameterValue(const ShaderParameterDefinition& return JsonValue(value.enumValue); case ShaderParameterType::Text: return JsonValue(value.textValue); + case ShaderParameterType::Trigger: + return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front()); case ShaderParameterType::Float: return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front()); case ShaderParameterType::Vec2: diff --git a/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeParameterUtils.cpp b/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeParameterUtils.cpp index 006560d..dc2ecf4 100644 --- a/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeParameterUtils.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/runtime/RuntimeParameterUtils.cpp @@ -100,6 +100,9 @@ ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition& case ShaderParameterType::Text: value.textValue = NormalizeTextValue(definition.defaultTextValue, definition.maxLength); break; + case ShaderParameterType::Trigger: + value.numberValues = { 0.0, -1000000.0 }; + break; } return value; } @@ -190,6 +193,14 @@ bool NormalizeAndValidateParameterValue(const ShaderParameterDefinition& definit } normalizedValue.textValue = NormalizeTextValue(value.asString(), definition.maxLength); return true; + case ShaderParameterType::Trigger: + if (!value.isNumber() && !value.isBoolean()) + { + error = "Expected numeric or boolean value for trigger parameter '" + definition.id + "'."; + return false; + } + normalizedValue.numberValues = { value.isNumber() ? std::max(0.0, std::floor(value.asNumber())) : 0.0, -1000000.0 }; + return true; } return false; diff --git a/apps/LoopThroughWithOpenGLCompositing/shader/ShaderCompiler.cpp b/apps/LoopThroughWithOpenGLCompositing/shader/ShaderCompiler.cpp index 36cf4ac..64ceda3 100644 --- a/apps/LoopThroughWithOpenGLCompositing/shader/ShaderCompiler.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/shader/ShaderCompiler.cpp @@ -32,6 +32,7 @@ std::string SlangCBufferTypeForParameter(ShaderParameterType type) case ShaderParameterType::Boolean: return "bool"; case ShaderParameterType::Enum: return "int"; case ShaderParameterType::Text: return ""; + case ShaderParameterType::Trigger: return "int"; } return "float"; } @@ -52,6 +53,12 @@ std::string BuildParameterUniforms(const std::vector& { if (definition.type == ShaderParameterType::Text) continue; + if (definition.type == ShaderParameterType::Trigger) + { + source << "\tint " << definition.id << ";\n"; + source << "\tfloat " << definition.id << "Time;\n"; + continue; + } source << "\t" << SlangCBufferTypeForParameter(definition.type) << " " << definition.id << ";\n"; } return source.str(); diff --git a/apps/LoopThroughWithOpenGLCompositing/shader/ShaderPackageRegistry.cpp b/apps/LoopThroughWithOpenGLCompositing/shader/ShaderPackageRegistry.cpp index 045c724..4bc9d02 100644 --- a/apps/LoopThroughWithOpenGLCompositing/shader/ShaderPackageRegistry.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/shader/ShaderPackageRegistry.cpp @@ -72,6 +72,11 @@ bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType& type = ShaderParameterType::Text; return true; } + if (typeName == "trigger") + { + type = ShaderParameterType::Trigger; + return true; + } return false; } diff --git a/apps/LoopThroughWithOpenGLCompositing/shader/ShaderTypes.h b/apps/LoopThroughWithOpenGLCompositing/shader/ShaderTypes.h index cd6fbae..b6d11e3 100644 --- a/apps/LoopThroughWithOpenGLCompositing/shader/ShaderTypes.h +++ b/apps/LoopThroughWithOpenGLCompositing/shader/ShaderTypes.h @@ -12,7 +12,8 @@ enum class ShaderParameterType Color, Boolean, Enum, - Text + Text, + Trigger }; struct ShaderParameterOption diff --git a/docs/OSC_CONTROL.md b/docs/OSC_CONTROL.md index c8f0b3f..c827a89 100644 --- a/docs/OSC_CONTROL.md +++ b/docs/OSC_CONTROL.md @@ -70,6 +70,12 @@ Examples: Values are validated with the same shader parameter rules used by the REST API. Invalid values or unknown addresses are ignored and reported to the native debug output. +For `trigger` parameters, the OSC value is treated as a pulse. A simple integer or boolean message is enough: + +```text +/VideoShaderToys/trigger-flash/flash 1 +``` + ## Open Stage Control For simple scalar controls, set the widget address and target directly: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 6c8403c..1c4c28e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -474,7 +474,7 @@ components: type: string type: type: string - enum: [float, vec2, color, bool, enum] + enum: [float, vec2, color, bool, enum, text, trigger] min: type: array items: diff --git a/shaders/trigger-ripple/shader.json b/shaders/trigger-ripple/shader.json new file mode 100644 index 0000000..fd190f6 --- /dev/null +++ b/shaders/trigger-ripple/shader.json @@ -0,0 +1,59 @@ +{ + "id": "trigger-ripple", + "name": "Trigger Ripple", + "description": "A water-drop style ripple that expands across the video whenever the trigger is pressed.", + "category": "Utility", + "entryPoint": "shadeVideo", + "parameters": [ + { + "id": "drop", + "label": "Drop", + "type": "trigger" + }, + { + "id": "center", + "label": "Center", + "type": "vec2", + "default": [0.5, 0.5], + "min": [0.0, 0.0], + "max": [1.0, 1.0], + "step": [0.01, 0.01] + }, + { + "id": "strength", + "label": "Strength", + "type": "float", + "default": 0.12, + "min": 0.0, + "max": 0.3, + "step": 0.001 + }, + { + "id": "speed", + "label": "Duration", + "type": "float", + "default": 0.3, + "min": 0.3, + "max": 5.0, + "step": 0.01 + }, + { + "id": "width", + "label": "Wave Width", + "type": "float", + "default": 0.09, + "min": 0.01, + "max": 0.25, + "step": 0.001 + }, + { + "id": "damping", + "label": "Damping", + "type": "float", + "default": 0.25, + "min": 0.05, + "max": 3.0, + "step": 0.05 + } + ] +} diff --git a/shaders/trigger-ripple/shader.slang b/shaders/trigger-ripple/shader.slang new file mode 100644 index 0000000..dbdeadc --- /dev/null +++ b/shaders/trigger-ripple/shader.slang @@ -0,0 +1,34 @@ +float4 shadeVideo(ShaderContext context) +{ + if (drop <= 0) + return context.sourceColor; + + float age = max(context.time - dropTime, 0.0); + float2 aspect = float2(context.outputResolution.x / max(context.outputResolution.y, 1.0), 1.0); + float2 fromDrop = (context.uv - center) * aspect; + float radius = length(fromDrop); + + float2 c0 = (float2(0.0, 0.0) - center) * aspect; + float2 c1 = (float2(1.0, 0.0) - center) * aspect; + float2 c2 = (float2(0.0, 1.0) - center) * aspect; + float2 c3 = (float2(1.0, 1.0) - center) * aspect; + float maxRadius = max(max(length(c0), length(c1)), max(length(c2), length(c3))); + float progress = saturate(age / max(speed, 0.001)); + float waveRadius = progress * maxRadius; + float distanceToWave = radius - waveRadius; + float waveWidth = max(width * maxRadius, 0.0001); + float ring = (1.0 - smoothstep(waveWidth, waveWidth * 1.65, abs(distanceToWave))) * exp(-progress * damping * 0.35); + float crest = 1.0 - smoothstep(0.0, waveWidth * 0.35, abs(distanceToWave)); + float ripple = ring * (distanceToWave >= 0.0 ? 1.0 : -0.55); + + float2 direction = radius > 0.0001 ? fromDrop / radius : float2(0.0, 0.0); + float2 uvOffset = direction * (ripple * strength); + uvOffset.x /= aspect.x; + + float2 refractedUv = clamp(context.uv - uvOffset, float2(0.0, 0.0), float2(1.0, 1.0)); + float4 refracted = sampleVideo(refractedUv); + + float highlight = saturate(ring * 0.75 + crest * 0.55); + refracted.rgb += highlight * 0.075; + return saturate(refracted); +} diff --git a/tests/RuntimeParameterUtilsTests.cpp b/tests/RuntimeParameterUtilsTests.cpp index 42d8a1d..3543116 100644 --- a/tests/RuntimeParameterUtilsTests.cpp +++ b/tests/RuntimeParameterUtilsTests.cpp @@ -167,6 +167,28 @@ void TestTextNormalization() error.clear(); Expect(!NormalizeAndValidateParameterValue(definition, JsonValue(12.0), value, error), "text rejects non-string values"); } + +void TestTriggerNormalization() +{ + ShaderParameterDefinition definition; + definition.id = "burst"; + definition.type = ShaderParameterType::Trigger; + + ShaderParameterValue defaultValue = DefaultValueForDefinition(definition); + Expect(defaultValue.numberValues.size() == 2 && defaultValue.numberValues[0] == 0.0, "trigger defaults to an unfired count"); + Expect(defaultValue.numberValues[1] < 0.0, "trigger default time is safely in the past"); + + ShaderParameterValue value; + std::string error; + Expect(NormalizeAndValidateParameterValue(definition, JsonValue(4.8), value, error), "trigger accepts numeric counts"); + Expect(value.numberValues.size() == 2 && value.numberValues[0] == 4.0, "trigger count is floored to an integer"); + + error.clear(); + Expect(NormalizeAndValidateParameterValue(definition, JsonValue(true), value, error), "trigger accepts boolean pulse values"); + + error.clear(); + Expect(!NormalizeAndValidateParameterValue(definition, JsonValue("fire"), value, error), "trigger rejects string values"); +} } int main() @@ -177,6 +199,7 @@ int main() TestColorAndBooleanNormalization(); TestEnumAndDefaults(); TestTextNormalization(); + TestTriggerNormalization(); if (gFailures != 0) { diff --git a/tests/ShaderPackageRegistryTests.cpp b/tests/ShaderPackageRegistryTests.cpp index 6cb10c4..3579edf 100644 --- a/tests/ShaderPackageRegistryTests.cpp +++ b/tests/ShaderPackageRegistryTests.cpp @@ -64,7 +64,8 @@ void TestValidManifest() { "id": "mode", "label": "Mode", "type": "enum", "default": "soft", "options": [ { "value": "soft", "label": "Soft" }, { "value": "hard", "label": "Hard" } - ] } + ] }, + { "id": "flash", "label": "Flash", "type": "trigger" } ] })"); @@ -76,8 +77,9 @@ void TestValidManifest() Expect(package.textureAssets.size() == 1 && package.textureAssets[0].id == "maskTex", "texture assets parse"); Expect(package.fontAssets.size() == 1 && package.fontAssets[0].id == "inter", "font assets parse"); Expect(package.temporal.enabled && package.temporal.effectiveHistoryLength == 4, "temporal history is capped"); - Expect(package.parameters.size() == 3, "parameters parse"); + Expect(package.parameters.size() == 4, "parameters parse"); Expect(package.parameters[1].type == ShaderParameterType::Text && package.parameters[1].defaultTextValue == "LIVE", "text parameter parses"); + Expect(package.parameters[3].type == ShaderParameterType::Trigger, "trigger parameter parses"); std::filesystem::remove_all(root); } diff --git a/ui/src/components/ParameterField.jsx b/ui/src/components/ParameterField.jsx index b8d51a3..3c5c96d 100644 --- a/ui/src/components/ParameterField.jsx +++ b/ui/src/components/ParameterField.jsx @@ -99,9 +99,9 @@ export function ParameterField({ layer, parameter, onParameterChange }) { } = useThrottledParameterValue(parameter, onParameterChange); const defaultValue = parameter.defaultValue; - const resetDisabled = defaultValue === undefined || valuesMatch(draftValue, defaultValue); + const resetDisabled = parameter.type === "trigger" || defaultValue === undefined || valuesMatch(draftValue, defaultValue); const resetParameter = () => { - if (defaultValue !== undefined) { + if (parameter.type !== "trigger" && defaultValue !== undefined) { sendValue(defaultValue); } }; @@ -318,5 +318,22 @@ export function ParameterField({ layer, parameter, onParameterChange }) { ); } + if (parameter.type === "trigger") { + const triggerCount = Number(draftValue ?? 0); + return ( +
+ {header} + + +
+ ); + } + return null; } diff --git a/ui/src/components/ParameterValueDisplay.jsx b/ui/src/components/ParameterValueDisplay.jsx index 8dab0f5..f3a1ba4 100644 --- a/ui/src/components/ParameterValueDisplay.jsx +++ b/ui/src/components/ParameterValueDisplay.jsx @@ -12,6 +12,9 @@ export function formatParameterValue(parameterType, value) { if (parameterType === "bool") { return value ? "Enabled" : "Disabled"; } + if (parameterType === "trigger") { + return `Triggered ${Number(value ?? 0)} time${Number(value ?? 0) === 1 ? "" : "s"}`; + } return `${value ?? ""}`; }