From 6ea6971dd685fb8bc5391f6cefd003e901d51c50 Mon Sep 17 00:00:00 2001 From: Aiden Date: Fri, 8 May 2026 20:32:19 +1000 Subject: [PATCH] more shaders and updates/changes --- README.md | 1 + shaders/crt-bulge/shader.json | 102 ++++++++++++++++++++++ shaders/crt-bulge/shader.slang | 71 ++++++++++++++++ shaders/vhs/shader.json | 42 +++++++++- shaders/vhs/shader.slang | 119 +++++++++++++++++++++++--- shaders/video-plane-3d/shader.json | 121 +++++++++++++++++++++++++++ shaders/video-plane-3d/shader.slang | 84 +++++++++++++++++++ shaders/video-transform/shader.json | 29 +++++++ shaders/video-transform/shader.slang | 34 ++++++++ 9 files changed, 589 insertions(+), 14 deletions(-) create mode 100644 shaders/crt-bulge/shader.json create mode 100644 shaders/crt-bulge/shader.slang create mode 100644 shaders/video-plane-3d/shader.json create mode 100644 shaders/video-plane-3d/shader.slang diff --git a/README.md b/README.md index 6596edc..3984b56 100644 --- a/README.md +++ b/README.md @@ -274,3 +274,4 @@ If `SLANG_ROOT` is not set, the workflow falls back to the repo-local default un - allow shaders to read other shaders data store based on name? or output over OSC - Mipmapping for shader-declared textures - Anotate included shaders +- allow 3 vector exposed controls diff --git a/shaders/crt-bulge/shader.json b/shaders/crt-bulge/shader.json new file mode 100644 index 0000000..069e853 --- /dev/null +++ b/shaders/crt-bulge/shader.json @@ -0,0 +1,102 @@ +{ + "id": "crt-bulge", + "name": "CRT Bulge", + "description": "Warps the image like convex CRT glass, with optional rounded screen edges and vignette darkening.", + "category": "Distortion", + "entryPoint": "shadeVideo", + "parameters": [ + { + "id": "bulgeAmount", + "label": "Bulge", + "type": "float", + "default": -0.04, + "min": -0.5, + "max": 0.8, + "step": 0.01, + "description": "Positive values swell the center outward; negative values pinch it inward." + }, + { + "id": "zoom", + "label": "Zoom", + "type": "float", + "default": 1.04, + "min": 0.5, + "max": 2, + "step": 0.01, + "description": "Scales the source before distortion, useful for hiding warped edges." + }, + { + "id": "edgeRoundness", + "label": "Edge Roundness", + "type": "float", + "default": 0.08, + "min": 0, + "max": 0.35, + "step": 0.01, + "description": "Rounds the visible screen corners like older CRT glass." + }, + { + "id": "edgeFeather", + "label": "Edge Feather", + "type": "float", + "default": 2, + "min": 0, + "max": 24, + "step": 0.1, + "description": "Softens the rounded screen edge in pixels." + }, + { + "id": "sourceEdgeFeather", + "label": "Source Edge Feather", + "type": "float", + "default": 1.5, + "min": 0, + "max": 16, + "step": 0.1, + "description": "Antialiases warped source edges when the distortion reveals outside-frame pixels." + }, + { + "id": "vignetteAmount", + "label": "Vignette", + "type": "float", + "default": 0.18, + "min": 0, + "max": 1, + "step": 0.01, + "description": "Darkens the glass toward the screen edges." + }, + { + "id": "edgeMode", + "label": "Edge Mode", + "type": "enum", + "default": "black", + "options": [ + { + "value": "black", + "label": "Black" + }, + { + "value": "clamp", + "label": "Clamp" + }, + { + "value": "mirror", + "label": "Mirror" + } + ], + "description": "Chooses how warped samples outside the source frame are filled." + }, + { + "id": "outsideColor", + "label": "Outside Color", + "type": "color", + "default": [ + 0, + 0, + 0, + 1 + ], + "description": "Color used outside the curved screen or source frame." + } + ] +} diff --git a/shaders/crt-bulge/shader.slang b/shaders/crt-bulge/shader.slang new file mode 100644 index 0000000..6bed29a --- /dev/null +++ b/shaders/crt-bulge/shader.slang @@ -0,0 +1,71 @@ +float mirroredCoordinate(float coordinate) +{ + float wrapped = frac(coordinate * 0.5) * 2.0; + return wrapped <= 1.0 ? wrapped : 2.0 - wrapped; +} + +float roundedBoxMask(float2 point, float2 halfSize, float radius, float feather) +{ + float2 distanceToEdge = abs(point) - (halfSize - radius); + float outsideDistance = length(max(distanceToEdge, float2(0.0, 0.0))) - radius; + 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 sourceBoundsMask(float2 uv, float2 resolution) +{ + float2 pixel = 1.0 / max(resolution, float2(1.0, 1.0)); + float2 feather = pixel * max(sourceEdgeFeather, 0.0); + float left = smoothstep(0.0, max(feather.x, 0.00001), uv.x); + float right = 1.0 - smoothstep(1.0 - max(feather.x, 0.00001), 1.0, uv.x); + float top = smoothstep(0.0, max(feather.y, 0.00001), uv.y); + float bottom = 1.0 - smoothstep(1.0 - max(feather.y, 0.00001), 1.0, uv.y); + return saturate(left * right * top * bottom); +} + +float2 applyBulge(float2 uv, float2 resolution) +{ + float2 centered = uv * 2.0 - 1.0; + float aspect = resolution.x / max(resolution.y, 1.0); + float2 aspectCentered = float2(centered.x * aspect, centered.y); + float radiusSq = dot(aspectCentered, aspectCentered); + float amount = clamp(bulgeAmount, -0.95, 0.95); + float scale = 1.0 / max(1.0 + amount * radiusSq, 0.05); + return centered * scale / max(zoom, 0.001) * 0.5 + 0.5; +} + +float4 sampleWarped(float2 uv, float2 resolution, out bool insideSource) +{ + insideSource = uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0; + + if (edgeMode == 1) + return sampleVideo(clamp(uv, 0.0, 1.0)); + if (edgeMode == 2) + return sampleVideo(float2(mirroredCoordinate(uv.x), mirroredCoordinate(uv.y))); + + float edgeMask = sourceBoundsMask(uv, resolution); + float4 color = sampleVideo(clamp(uv, 0.0, 1.0)); + return lerp(outsideColor, color, edgeMask); +} + +float4 shadeVideo(ShaderContext context) +{ + float2 resolution = max(context.outputResolution, float2(1.0, 1.0)); + float2 sourceUv = applyBulge(context.uv, resolution); + + bool insideSource = false; + float4 color = sampleWarped(sourceUv, resolution, insideSource); + + float2 centered = context.uv * 2.0 - 1.0; + float feather = max(edgeFeather, 0.0) / min(resolution.x, resolution.y); + float screenMask = roundedBoxMask(centered, float2(1.0, 1.0), saturate(edgeRoundness), feather); + color = lerp(outsideColor, color, screenMask); + + float2 aspectCentered = float2(centered.x * resolution.x / max(resolution.y, 1.0), centered.y); + float edgeDistance = saturate(length(aspectCentered) * 0.72); + float vignette = lerp(1.0, 1.0 - saturate(vignetteAmount), smoothstep(0.35, 1.05, edgeDistance)); + color.rgb *= vignette; + + return saturate(color); +} diff --git a/shaders/vhs/shader.json b/shaders/vhs/shader.json index 779bf2c..978a0e4 100644 --- a/shaders/vhs/shader.json +++ b/shaders/vhs/shader.json @@ -69,7 +69,7 @@ "id": "vignetteAmount", "label": "Vignette", "type": "float", - "default": 0.18, + "default": 0.3, "min": 0, "max": 0.6, "step": 0.01, @@ -154,6 +154,46 @@ "max": 6, "step": 0.05, "description": "Scale of the generated noise pattern." + }, + { + "id": "scanlineAmount", + "label": "Scanlines", + "type": "float", + "default": 0.08, + "min": 0, + "max": 0.35, + "step": 0.005, + "description": "Subtle alternating-field luma modulation." + }, + { + "id": "chromaCrawlAmount", + "label": "Chroma Crawl", + "type": "float", + "default": 0.035, + "min": 0, + "max": 0.2, + "step": 0.005, + "description": "Moving color shimmer around high-contrast edges." + }, + { + "id": "generationLoss", + "label": "Generation Loss", + "type": "float", + "default": 0.18, + "min": 0, + "max": 1, + "step": 0.01, + "description": "Raises blacks, softens detail, lowers contrast, and desaturates chroma like copied tape." + }, + { + "id": "sharpnessDrift", + "label": "Sharpness Drift", + "type": "float", + "default": 0.12, + "min": 0, + "max": 0.6, + "step": 0.01, + "description": "Slowly varies picture softness to mimic unstable tape focus." } ] } diff --git a/shaders/vhs/shader.slang b/shaders/vhs/shader.slang index 2a31c4c..70f108e 100644 --- a/shaders/vhs/shader.slang +++ b/shaders/vhs/shader.slang @@ -46,11 +46,16 @@ float noiseHash(float2 p) return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123); } -// Gold Noise (c)2015 dcerisano@standard3d.com, adapted for Slang. -float goldNoise(float2 xy, float seed) +float staticHash(float2 p) { - const float phi = 1.61803398874989484820459; - return frac(tan(distance(xy * phi, xy) * seed) * xy.x); + float3 p3 = frac(float3(p.x, p.y, p.x) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return frac((p3.x + p3.y) * p3.z); +} + +float seededStaticHash(float2 p, float seed) +{ + return staticHash(p + float2(seed * 37.13, seed * 17.71)); } float grainScalar(float2 uv) @@ -65,11 +70,13 @@ float3 animatedChromaGrain(float2 uv, float time, float2 outputResolution, float // chroma blocks rather than simply lower-frequency smooth noise. float2 baseUv = uv * outputResolution * float2(0.85, 0.95) / safeGrainSize; float2 grainUv = floor(baseUv) + 0.5; - float2 drift = float2(time * 19.7, time * 23.3); + float frame = floor(time * 59.94); - float r = grainScalar(grainUv + drift + float2(13.1, 71.7)); - float g = grainScalar(grainUv * float2(1.03, 0.97) + drift * 1.11 + float2(47.2, 19.4)); - float b = grainScalar(grainUv * float2(0.96, 1.05) + drift * 0.91 + float2(83.6, 53.8)); + // Change the grain field per frame instead of drifting it through UV space; + // continuous drift can alias into horizontal bands that march down-frame. + float r = staticHash(grainUv + float2(frame * 17.0 + 13.1, frame * 3.0 + 71.7)); + float g = staticHash(grainUv * float2(1.03, 0.97) + float2(frame * 11.0 + 47.2, frame * 5.0 + 19.4)); + float b = staticHash(grainUv * float2(0.96, 1.05) + float2(frame * 7.0 + 83.6, frame * 13.0 + 53.8)); return float3(r, g, b) * 2.0 - 1.0; } @@ -111,15 +118,16 @@ float3 analogStatic(float2 uv, float time, float2 outputResolution) // Several differently skewed hashes keep the snow from forming obvious // diagonal or grid patterns at broadcast frame cadence. float2 goldPixel = pixel + float2(0.37, 0.61) + frame; - float snowA = goldNoise(goldPixel, seed + 0.1); - float snowB = goldNoise(goldPixel * float2(0.37, 2.11) + float2(19.0, 41.0), seed + 0.2); - float snowC = goldNoise(goldPixel * float2(1.73, 0.81) + float2(53.0, 7.0), seed + 0.3); + float snowA = seededStaticHash(goldPixel, seed + 0.1); + float snowB = seededStaticHash(goldPixel * float2(0.37, 2.11) + float2(19.0, 41.0), seed + 0.2); + float snowC = seededStaticHash(goldPixel * float2(1.73, 0.81) + float2(53.0, 7.0), seed + 0.3); float snow = (snowA * 0.72 + snowB * 0.28) * 2.0 - 1.0; float lineNoise = tapeLineNoise(uv, time, safeResolution); - float dropoutSeed = goldNoise(float2(floor(uv.y * safeResolution.y * 0.25) + 1.0, frame + 2.0), seed + 0.4); + float dropoutSeed = seededStaticHash(float2(floor(uv.y * safeResolution.y * 0.25) + 1.0, frame + 2.0), seed + 0.4); float dropout = smoothstep(0.965, 1.0, dropoutSeed); - float fleck = smoothstep(0.988, 1.0, snowA) - smoothstep(0.0, 0.012, snowC); + float fleckSeed = seededStaticHash(pixel + float2(frame * 13.0, -frame * 7.0), seed + 0.5); + float fleck = smoothstep(0.992, 1.0, fleckSeed) - smoothstep(0.0, 0.008, snowC); float scan = sin(uv.y * safeResolution.y * 3.14159265); float scanMask = 0.55 + 0.45 * scan * scan; @@ -146,6 +154,85 @@ float3 softBloom(float2 uv, float2 outputResolution, float radius) return sum; } +float3 softCrossBlur(float2 uv, float2 outputResolution, float radius) +{ + float2 pixel = 1.0 / max(outputResolution, float2(1.0, 1.0)); + float2 offset = pixel * radius; + float3 sum = sampleVideo(frac(uv)).rgb * 0.40; + sum += sampleVideo(frac(uv + float2(offset.x, 0.0))).rgb * 0.15; + sum += sampleVideo(frac(uv - float2(offset.x, 0.0))).rgb * 0.15; + sum += sampleVideo(frac(uv + float2(0.0, offset.y))).rgb * 0.15; + sum += sampleVideo(frac(uv - float2(0.0, offset.y))).rgb * 0.15; + return sum; +} + +float3 applyChromaCrawl(float3 color, float2 uv, float time, float2 outputResolution) +{ + float amount = saturate(chromaCrawlAmount); + if (amount <= 0.0001) + return color; + + float2 pixel = 1.0 / max(outputResolution, float2(1.0, 1.0)); + float lumaCenter = dot(color, float3(0.299, 0.587, 0.114)); + float lumaX = dot(sampleVideo(frac(uv + float2(pixel.x, 0.0))).rgb, float3(0.299, 0.587, 0.114)); + float lumaY = dot(sampleVideo(frac(uv + float2(0.0, pixel.y))).rgb, float3(0.299, 0.587, 0.114)); + float edge = saturate((abs(lumaX - lumaCenter) + abs(lumaY - lumaCenter)) * 6.0); + float phase = sin(uv.y * outputResolution.y * 1.35 + time * 36.0) * cos(uv.x * outputResolution.x * 0.55 - time * 21.0); + float2 crawlOffset = float2(phase, -phase * 0.35) * pixel * (1.0 + amount * 8.0); + + float3 shiftedA = sampleVideo(frac(uv + crawlOffset)).rgb; + float3 shiftedB = sampleVideo(frac(uv - crawlOffset * 0.75)).rgb; + float3 crawled = color; + crawled.r = lerp(color.r, shiftedA.r, edge * amount); + crawled.b = lerp(color.b, shiftedB.b, edge * amount); + return crawled; +} + +float3 applyGenerationLoss(float3 color, float2 uv, float2 outputResolution) +{ + float loss = saturate(generationLoss); + if (loss <= 0.0001) + return color; + + float3 softened = softCrossBlur(uv, outputResolution, 0.85 + loss * 2.2); + color = lerp(color, softened, loss * 0.42); + + float luma = dot(color, float3(0.299, 0.587, 0.114)); + float3 gray = float3(luma, luma, luma); + color = lerp(color, gray, loss * 0.32); + color = (color - 0.5) * (1.0 - loss * 0.18) + 0.5; + color = color * (1.0 - loss * 0.08) + float3(0.035, 0.035, 0.04) * loss; + return color; +} + +float3 applySharpnessDrift(float3 color, float2 uv, float time, float2 outputResolution) +{ + float drift = saturate(sharpnessDrift); + if (drift <= 0.0001) + return color; + + float wobble = 0.5 + 0.5 * sin(time * 1.7 + sin(time * 0.37) * 2.0); + float radius = 0.35 + wobble * 2.25; + float3 softened = softCrossBlur(uv, outputResolution, radius); + return lerp(color, softened, drift * (0.35 + 0.65 * wobble)); +} + +float3 applySubtleScanlines(float3 color, float2 uv, float time, float2 outputResolution) +{ + float amount = saturate(scanlineAmount); + if (amount <= 0.0001) + return color; + + float scan = sin((uv.y * outputResolution.y + floor(time * 59.94) * 0.5) * 3.14159265); + float field = 0.5 + 0.5 * scan; + float luma = dot(color, float3(0.299, 0.587, 0.114)); + float visibility = lerp(1.0, 0.45, saturate(luma)); + float modulation = 1.0 - amount * visibility * (0.35 + 0.65 * field); + color.rgb *= modulation; + color.rgb += amount * 0.015 * (1.0 - field); + return color; +} + float3 blurVhs(float2 uv, float d, int sampleCount) { float3 sum = float3(0.0, 0.0, 0.0); @@ -241,6 +328,10 @@ float4 finishVhs(ShaderContext context) color = lerp(color, bloomSource, bloomAmount * 0.18); color += bloomSource * float3(1.0, 0.96, 0.92) * bloomMask * 0.24; + color = applySharpnessDrift(color, context.uv, time, context.outputResolution); + color = applyGenerationLoss(color, context.uv, context.outputResolution); + color = applyChromaCrawl(color, context.uv, time, context.outputResolution); + float3 speckle = animatedChromaGrain(context.uv, time, context.outputResolution, noiseSize); float luma = dot(color, float3(0.299, 0.587, 0.114)); float noiseMask = lerp(0.65, 1.0, 1.0 - saturate(luma)); @@ -262,6 +353,8 @@ float4 finishVhs(ShaderContext context) color = color * (1.0 - fadeAmount * 0.08) + float3(0.055, 0.055, 0.065) * fadeAmount; color = lerp(color, softBloom(context.uv, context.outputResolution, 1.0 + smear), fadeAmount * 0.12); + color = applySubtleScanlines(color, context.uv, time, context.outputResolution); + float vignetteBase = context.uv.x * (1.0 - context.uv.x) * context.uv.y * (1.0 - context.uv.y); float vignette = saturate(pow(vignetteBase * 16.0, 0.22)); color *= lerp(1.0 - vignetteAmount, 1.0, vignette); diff --git a/shaders/video-plane-3d/shader.json b/shaders/video-plane-3d/shader.json new file mode 100644 index 0000000..7c42bc7 --- /dev/null +++ b/shaders/video-plane-3d/shader.json @@ -0,0 +1,121 @@ +{ + "id": "video-plane-3d", + "name": "Video Plane 3D", + "description": "Places the video on a perspective 2D plane in 3D space with camera FOV, XYZ position, and pan/tilt/roll controls.", + "category": "Projection", + "entryPoint": "shadeVideo", + "parameters": [ + { + "id": "fovDegrees", + "label": "FOV", + "type": "float", + "default": 45, + "min": 5, + "max": 150, + "step": 0.1, + "description": "Virtual camera vertical field of view in degrees." + }, + { + "id": "positionX", + "label": "X", + "type": "float", + "default": 0, + "min": -4, + "max": 4, + "step": 0.01, + "description": "Horizontal plane position in world units." + }, + { + "id": "positionY", + "label": "Y", + "type": "float", + "default": 0, + "min": -4, + "max": 4, + "step": 0.01, + "description": "Vertical plane position in world units." + }, + { + "id": "positionZ", + "label": "Z", + "type": "float", + "default": 2.2, + "min": 0.1, + "max": 10, + "step": 0.01, + "description": "Depth of the plane in front of the virtual camera." + }, + { + "id": "panDegrees", + "label": "Pan", + "type": "float", + "default": 0, + "min": -180, + "max": 180, + "step": 0.1, + "description": "Rotates the plane left/right around its vertical axis." + }, + { + "id": "tiltDegrees", + "label": "Tilt", + "type": "float", + "default": 0, + "min": -120, + "max": 120, + "step": 0.1, + "description": "Rotates the plane up/down around its horizontal axis." + }, + { + "id": "rollDegrees", + "label": "Roll", + "type": "float", + "default": 0, + "min": -180, + "max": 180, + "step": 0.1, + "description": "Rotates the plane around its face normal." + }, + { + "id": "planeScale", + "label": "Plane Scale", + "type": "float", + "default": 1.4, + "min": 0.05, + "max": 6, + "step": 0.01, + "description": "Height of the video plane in world units; width follows the source aspect ratio." + }, + { + "id": "edgeFeather", + "label": "Edge Feather", + "type": "float", + "default": 1.5, + "min": 0, + "max": 24, + "step": 0.1, + "description": "Softens the plane edge in source pixels." + }, + { + "id": "backgroundMix", + "label": "Background Mix", + "type": "float", + "default": 0, + "min": 0, + "max": 1, + "step": 0.01, + "description": "Mixes the original video behind the projected plane." + }, + { + "id": "outsideColor", + "label": "Outside Color", + "type": "color", + "default": [ + 0, + 0, + 0, + 1 + ], + "description": "Color used where the camera ray misses the plane." + } + ] +} diff --git a/shaders/video-plane-3d/shader.slang b/shaders/video-plane-3d/shader.slang new file mode 100644 index 0000000..0bb8238 --- /dev/null +++ b/shaders/video-plane-3d/shader.slang @@ -0,0 +1,84 @@ +static const float PI = 3.14159265358979323846; + +float radiansFromDegrees(float degrees) +{ + return degrees * (PI / 180.0); +} + +float3 rotateX(float3 p, float angle) +{ + float s = sin(angle); + float c = cos(angle); + return float3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); +} + +float3 rotateY(float3 p, float angle) +{ + float s = sin(angle); + float c = cos(angle); + return float3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); +} + +float3 rotateZ(float3 p, float angle) +{ + float s = sin(angle); + float c = cos(angle); + return float3(c * p.x - s * p.y, s * p.x + c * p.y, p.z); +} + +float3 rotateWorldToPlane(float3 value) +{ + float pan = radiansFromDegrees(panDegrees); + float tilt = radiansFromDegrees(tiltDegrees); + float roll = radiansFromDegrees(rollDegrees); + return rotateZ(rotateX(rotateY(value, -pan), -tilt), -roll); +} + +float planeEdgeMask(float2 uv, float2 inputResolution) +{ + float2 feather = max(edgeFeather, 0.0) / max(inputResolution, float2(1.0, 1.0)); + feather = max(feather, float2(0.00001, 0.00001)); + + float left = smoothstep(0.0, feather.x, uv.x); + float right = 1.0 - smoothstep(1.0 - feather.x, 1.0, uv.x); + float top = smoothstep(0.0, feather.y, uv.y); + float bottom = 1.0 - smoothstep(1.0 - feather.y, 1.0, uv.y); + return saturate(left * right * top * bottom); +} + +float4 shadeVideo(ShaderContext context) +{ + float2 outputResolution = max(context.outputResolution, float2(1.0, 1.0)); + float outputAspect = outputResolution.x / outputResolution.y; + float sourceAspect = context.inputResolution.x / max(context.inputResolution.y, 1.0); + float tanHalfFov = tan(radiansFromDegrees(clamp(fovDegrees, 5.0, 150.0)) * 0.5); + + float2 screen = float2(context.uv.x * 2.0 - 1.0, 1.0 - context.uv.y * 2.0); + float3 rayOrigin = float3(0.0, 0.0, 0.0); + float3 rayDirection = normalize(float3(screen.x * outputAspect * tanHalfFov, screen.y * tanHalfFov, 1.0)); + + float3 planePosition = float3(positionX, positionY, max(positionZ, 0.001)); + float3 localOrigin = rotateWorldToPlane(rayOrigin - planePosition); + float3 localDirection = rotateWorldToPlane(rayDirection); + + float backgroundAmount = saturate(backgroundMix); + float4 background = float4(lerp(outsideColor.rgb, context.sourceColor.rgb, backgroundAmount), 1.0); + if (abs(localDirection.z) < 0.00001) + return background; + + float hitDistance = -localOrigin.z / localDirection.z; + if (hitDistance <= 0.0) + return background; + + float3 localHit = localOrigin + localDirection * hitDistance; + float halfHeight = max(planeScale, 0.001) * 0.5; + float halfWidth = halfHeight * sourceAspect; + float2 planeUv = float2( + localHit.x / max(halfWidth * 2.0, 0.0001) + 0.5, + 0.5 - localHit.y / max(halfHeight * 2.0, 0.0001) + ); + + float mask = planeEdgeMask(planeUv, max(context.inputResolution, float2(1.0, 1.0))); + float4 planeColor = sampleVideo(clamp(planeUv, 0.0, 1.0)); + return saturate(lerp(background, planeColor, mask)); +} diff --git a/shaders/video-transform/shader.json b/shaders/video-transform/shader.json index a0c2a0d..92c566c 100644 --- a/shaders/video-transform/shader.json +++ b/shaders/video-transform/shader.json @@ -47,6 +47,35 @@ "step": 0.1, "description": "Rotates the source image around the frame center." }, + { + "id": "cropAspect", + "label": "Crop Aspect", + "type": "enum", + "default": "none", + "options": [ + { + "value": "none", + "label": "None" + }, + { + "value": "4x3", + "label": "4:3" + }, + { + "value": "3x2", + "label": "3:2" + }, + { + "value": "1x1", + "label": "1:1" + }, + { + "value": "9x16", + "label": "9:16" + } + ], + "description": "Crops the visible image to a centered preset aspect ratio without squeezing the source." + }, { "id": "edgeMode", "label": "Edge Mode", diff --git a/shaders/video-transform/shader.slang b/shaders/video-transform/shader.slang index e6f3714..1c5d665 100644 --- a/shaders/video-transform/shader.slang +++ b/shaders/video-transform/shader.slang @@ -28,8 +28,42 @@ float2 applyEdgeMode(float2 uv, out bool inside) return uv; } +float selectedCropAspect() +{ + if (cropAspect == 1) + return 4.0 / 3.0; + if (cropAspect == 2) + return 3.0 / 2.0; + if (cropAspect == 3) + return 1.0; + if (cropAspect == 4) + return 9.0 / 16.0; + return 0.0; +} + +bool insideCropWindow(float2 uv, float2 resolution) +{ + float targetAspect = selectedCropAspect(); + if (targetAspect <= 0.0) + return true; + + float outputAspect = resolution.x / max(resolution.y, 1.0); + float2 cropSize = float2(1.0, 1.0); + if (outputAspect > targetAspect) + cropSize.x = targetAspect / outputAspect; + else + cropSize.y = outputAspect / targetAspect; + + float2 cropMin = (1.0 - cropSize) * 0.5; + float2 cropMax = cropMin + cropSize; + return uv.x >= cropMin.x && uv.x <= cropMax.x && uv.y >= cropMin.y && uv.y <= cropMax.y; +} + float4 shadeVideo(ShaderContext context) { + if (!insideCropWindow(context.uv, max(context.outputResolution, float2(1.0, 1.0)))) + return outsideColor; + float safeZoom = max(zoom, 0.001); float2 sourceUv = (context.uv - 0.5) / safeZoom + 0.5; sourceUv -= pan;