Added trigger
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 1m33s
CI / Windows Release Package (push) Successful in 2m4s

This commit is contained in:
2026-05-06 14:01:23 +10:00
parent e59677c212
commit 6502344d0a
16 changed files with 235 additions and 8 deletions

View File

@@ -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. - Find a better UI library for react.
- Logs. - Logs.
- Continue source cleanup/refactoring. Pass 1 done - 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 - Add WebView2
- move to MSDF, typography rasterisation - move to MSDF, typography rasterisation
- abritary function/triggers for the shader

View File

@@ -187,6 +187,7 @@ Supported types:
| `bool` | `bool` | `true` or `false` | | `bool` | `bool` | `true` or `false` |
| `enum` | `int` | selected option index | | `enum` | `int` | selected option index |
| `text` | generated texture/helper | string | | `text` | generated texture/helper | string |
| `trigger` | `int <id>`, `float <id>Time` | pulse/count |
Float example: 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. 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: Parameter validation:
- Float values are clamped to `min`/`max` if provided. - Float values are clamped to `min`/`max` if provided.
@@ -321,6 +342,7 @@ Parameter validation:
- `color` must have exactly 4 numbers. - `color` must have exactly 4 numbers.
- Enum defaults must match one of the declared option values. - 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`. - 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. - Non-finite numeric values are rejected.
## Texture Assets ## Texture Assets

View File

@@ -75,6 +75,10 @@ bool GlobalParamsBuffer::Update(const RuntimeRenderState& state, unsigned availa
} }
case ShaderParameterType::Text: case ShaderParameterType::Text:
break; break;
case ShaderParameterType::Trigger:
AppendStd140Int(buffer, value.numberValues.empty() ? 0 : static_cast<int>(value.numberValues[0]));
AppendStd140Float(buffer, value.numberValues.size() > 1 ? static_cast<float>(value.numberValues[1]) : -1000000.0f);
break;
} }
} }

View File

@@ -136,6 +136,7 @@ std::string ShaderParameterTypeToString(ShaderParameterType type)
case ShaderParameterType::Boolean: return "bool"; case ShaderParameterType::Boolean: return "bool";
case ShaderParameterType::Enum: return "enum"; case ShaderParameterType::Enum: return "enum";
case ShaderParameterType::Text: return "text"; case ShaderParameterType::Text: return "text";
case ShaderParameterType::Trigger: return "trigger";
} }
return "unknown"; return "unknown";
} }
@@ -187,6 +188,11 @@ bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType&
type = ShaderParameterType::Text; type = ShaderParameterType::Text;
return true; return true;
} }
if (typeName == "trigger")
{
type = ShaderParameterType::Trigger;
return true;
}
return false; return false;
} }
@@ -1011,6 +1017,15 @@ bool RuntimeHost::UpdateLayerParameter(const std::string& layerId, const std::st
return false; 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::duration<double>>(std::chrono::steady_clock::now() - mStartTime).count();
value.numberValues = { previousCount + 1.0, triggerTime };
return true;
}
ShaderParameterValue normalized; ShaderParameterValue normalized;
if (!NormalizeAndValidateValue(*parameterIt, newValue, normalized, error)) if (!NormalizeAndValidateValue(*parameterIt, newValue, normalized, error))
return false; return false;
@@ -1057,6 +1072,15 @@ bool RuntimeHost::UpdateLayerParameterByControlKey(const std::string& layerKey,
return false; 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::duration<double>>(std::chrono::steady_clock::now() - mStartTime).count();
value.numberValues = { previousCount + 1.0, triggerTime };
return true;
}
ShaderParameterValue normalized; ShaderParameterValue normalized;
if (!NormalizeAndValidateValue(*parameterIt, newValue, normalized, error)) if (!NormalizeAndValidateValue(*parameterIt, newValue, normalized, error))
return false; return false;
@@ -1714,6 +1738,12 @@ void RuntimeHost::EnsureLayerDefaultsLocked(LayerPersistentState& layerState, co
} }
break; 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) if (!shouldNormalize)
@@ -2103,6 +2133,8 @@ JsonValue RuntimeHost::SerializeParameterValue(const ShaderParameterDefinition&
return JsonValue(value.enumValue); return JsonValue(value.enumValue);
case ShaderParameterType::Text: case ShaderParameterType::Text:
return JsonValue(value.textValue); return JsonValue(value.textValue);
case ShaderParameterType::Trigger:
return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front());
case ShaderParameterType::Float: case ShaderParameterType::Float:
return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front()); return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front());
case ShaderParameterType::Vec2: case ShaderParameterType::Vec2:

View File

@@ -100,6 +100,9 @@ ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition&
case ShaderParameterType::Text: case ShaderParameterType::Text:
value.textValue = NormalizeTextValue(definition.defaultTextValue, definition.maxLength); value.textValue = NormalizeTextValue(definition.defaultTextValue, definition.maxLength);
break; break;
case ShaderParameterType::Trigger:
value.numberValues = { 0.0, -1000000.0 };
break;
} }
return value; return value;
} }
@@ -190,6 +193,14 @@ bool NormalizeAndValidateParameterValue(const ShaderParameterDefinition& definit
} }
normalizedValue.textValue = NormalizeTextValue(value.asString(), definition.maxLength); normalizedValue.textValue = NormalizeTextValue(value.asString(), definition.maxLength);
return true; 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; return false;

View File

@@ -32,6 +32,7 @@ std::string SlangCBufferTypeForParameter(ShaderParameterType type)
case ShaderParameterType::Boolean: return "bool"; case ShaderParameterType::Boolean: return "bool";
case ShaderParameterType::Enum: return "int"; case ShaderParameterType::Enum: return "int";
case ShaderParameterType::Text: return ""; case ShaderParameterType::Text: return "";
case ShaderParameterType::Trigger: return "int";
} }
return "float"; return "float";
} }
@@ -52,6 +53,12 @@ std::string BuildParameterUniforms(const std::vector<ShaderParameterDefinition>&
{ {
if (definition.type == ShaderParameterType::Text) if (definition.type == ShaderParameterType::Text)
continue; 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"; source << "\t" << SlangCBufferTypeForParameter(definition.type) << " " << definition.id << ";\n";
} }
return source.str(); return source.str();

View File

@@ -72,6 +72,11 @@ bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType&
type = ShaderParameterType::Text; type = ShaderParameterType::Text;
return true; return true;
} }
if (typeName == "trigger")
{
type = ShaderParameterType::Trigger;
return true;
}
return false; return false;
} }

View File

@@ -12,7 +12,8 @@ enum class ShaderParameterType
Color, Color,
Boolean, Boolean,
Enum, Enum,
Text Text,
Trigger
}; };
struct ShaderParameterOption struct ShaderParameterOption

View File

@@ -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. 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 ## Open Stage Control
For simple scalar controls, set the widget address and target directly: For simple scalar controls, set the widget address and target directly:

View File

@@ -474,7 +474,7 @@ components:
type: string type: string
type: type:
type: string type: string
enum: [float, vec2, color, bool, enum] enum: [float, vec2, color, bool, enum, text, trigger]
min: min:
type: array type: array
items: items:

View File

@@ -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
}
]
}

View File

@@ -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);
}

View File

@@ -167,6 +167,28 @@ void TestTextNormalization()
error.clear(); error.clear();
Expect(!NormalizeAndValidateParameterValue(definition, JsonValue(12.0), value, error), "text rejects non-string values"); 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() int main()
@@ -177,6 +199,7 @@ int main()
TestColorAndBooleanNormalization(); TestColorAndBooleanNormalization();
TestEnumAndDefaults(); TestEnumAndDefaults();
TestTextNormalization(); TestTextNormalization();
TestTriggerNormalization();
if (gFailures != 0) if (gFailures != 0)
{ {

View File

@@ -64,7 +64,8 @@ void TestValidManifest()
{ "id": "mode", "label": "Mode", "type": "enum", "default": "soft", "options": [ { "id": "mode", "label": "Mode", "type": "enum", "default": "soft", "options": [
{ "value": "soft", "label": "Soft" }, { "value": "soft", "label": "Soft" },
{ "value": "hard", "label": "Hard" } { "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.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.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.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[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); std::filesystem::remove_all(root);
} }

View File

@@ -99,9 +99,9 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
} = useThrottledParameterValue(parameter, onParameterChange); } = useThrottledParameterValue(parameter, onParameterChange);
const defaultValue = parameter.defaultValue; const defaultValue = parameter.defaultValue;
const resetDisabled = defaultValue === undefined || valuesMatch(draftValue, defaultValue); const resetDisabled = parameter.type === "trigger" || defaultValue === undefined || valuesMatch(draftValue, defaultValue);
const resetParameter = () => { const resetParameter = () => {
if (defaultValue !== undefined) { if (parameter.type !== "trigger" && defaultValue !== undefined) {
sendValue(defaultValue); sendValue(defaultValue);
} }
}; };
@@ -318,5 +318,22 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
); );
} }
if (parameter.type === "trigger") {
const triggerCount = Number(draftValue ?? 0);
return (
<section className="parameter">
{header}
<button
type="button"
className="parameter__trigger"
onClick={() => sendValue(triggerCount + 1)}
>
Trigger
</button>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
return null; return null;
} }

View File

@@ -12,6 +12,9 @@ export function formatParameterValue(parameterType, value) {
if (parameterType === "bool") { if (parameterType === "bool") {
return value ? "Enabled" : "Disabled"; return value ? "Enabled" : "Disabled";
} }
if (parameterType === "trigger") {
return `Triggered ${Number(value ?? 0)} time${Number(value ?? 0) === 1 ? "" : "s"}`;
}
return `${value ?? ""}`; return `${value ?? ""}`;
} }