This commit is contained in:
2026-05-02 17:13:53 +10:00
parent 1a4c33b9dc
commit fb1bf01fef
19 changed files with 780 additions and 69 deletions

View File

@@ -26,7 +26,7 @@ Supported parameter types:
## Slang contract ## Slang contract
The runtime owns the fragment entry point, video decode, and final mix/bypass behavior. The runtime owns the fragment entry point, the UYVY-to-RGBA decode pass, and final mix/bypass behavior.
Your `shader.slang` file implements: Your `shader.slang` file implements:
@@ -40,7 +40,7 @@ float4 shadeVideo(ShaderContext context)
Available built-ins through `ShaderContext`: Available built-ins through `ShaderContext`:
- `uv` - `uv`
- `sourceColor` - `sourceColor` - the already-decoded full-resolution RGBA video color at `uv`
- `inputResolution` - `inputResolution`
- `outputResolution` - `outputResolution`
- `time` - `time`
@@ -52,4 +52,4 @@ Manifest parameters are exposed to the shader as globals named by their `id`.
Helper function: Helper function:
- `sampleVideo(float2 uv)` returns decoded RGBA video from the live DeckLink input. - `sampleVideo(float2 uv)` returns decoded full-resolution RGBA video from the live DeckLink input.

View File

@@ -247,6 +247,12 @@ bool ControlServer::HandleHttpRequest(SOCKET clientSocket, const std::string& re
if (shaderId && parameterId && value && mCallbacks.updateParameter) if (shaderId && parameterId && value && mCallbacks.updateParameter)
success = mCallbacks.updateParameter(shaderId->asString(), parameterId->asString(), SerializeJson(*value, false), actionError); success = mCallbacks.updateParameter(shaderId->asString(), parameterId->asString(), SerializeJson(*value, false), actionError);
} }
else if (path == "/api/reset-parameters")
{
const JsonValue* shaderId = root.find("shaderId");
if (shaderId && mCallbacks.resetParameters)
success = mCallbacks.resetParameters(shaderId->asString(), actionError);
}
else if (path == "/api/set-bypass") else if (path == "/api/set-bypass")
{ {
const JsonValue* bypass = root.find("bypass"); const JsonValue* bypass = root.find("bypass");

View File

@@ -18,6 +18,7 @@ public:
std::function<std::string()> getStateJson; std::function<std::string()> getStateJson;
std::function<bool(const std::string&, std::string&)> selectShader; std::function<bool(const std::string&, std::string&)> selectShader;
std::function<bool(const std::string&, const std::string&, const std::string&, std::string&)> updateParameter; std::function<bool(const std::string&, const std::string&, const std::string&, std::string&)> updateParameter;
std::function<bool(const std::string&, std::string&)> resetParameters;
std::function<bool(bool, std::string&)> setBypass; std::function<bool(bool, std::string&)> setBypass;
std::function<bool(double, std::string&)> setMixAmount; std::function<bool(double, std::string&)> setMixAmount;
std::function<bool(std::string&)> reloadShader; std::function<bool(std::string&)> reloadShader;

View File

@@ -202,6 +202,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
static HGLRC hRC = NULL; // Permenant Rendering context static HGLRC hRC = NULL; // Permenant Rendering context
static HDC hDC = NULL; // Private GDI Device context static HDC hDC = NULL; // Private GDI Device context
static OpenGLComposite* pOpenGLComposite = NULL; static OpenGLComposite* pOpenGLComposite = NULL;
static bool sInteractiveResize = false;
switch (message) switch (message)
{ {
@@ -289,6 +290,21 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
PostQuitMessage(0); PostQuitMessage(0);
break; break;
case WM_ENTERSIZEMOVE:
sInteractiveResize = true;
break;
case WM_EXITSIZEMOVE:
sInteractiveResize = false;
if (pOpenGLComposite)
{
RECT clientRect = {};
if (GetClientRect(hWnd, &clientRect))
pOpenGLComposite->resizeGL(static_cast<WORD>(clientRect.right - clientRect.left), static_cast<WORD>(clientRect.bottom - clientRect.top));
}
InvalidateRect(hWnd, NULL, FALSE);
break;
case WM_SIZE: case WM_SIZE:
try try
{ {
@@ -301,15 +317,22 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
} }
break; break;
case WM_ERASEBKGND:
return 1;
case WM_PAINT: case WM_PAINT:
try try
{ {
wglMakeCurrent(hDC, hRC); PAINTSTRUCT paint = {};
BeginPaint(hWnd, &paint);
EndPaint(hWnd, &paint);
if (pOpenGLComposite) if (!sInteractiveResize && pOpenGLComposite)
{
wglMakeCurrent(hDC, hRC);
pOpenGLComposite->paintGL(); pOpenGLComposite->paintGL();
wglMakeCurrent( NULL, NULL );
wglMakeCurrent( NULL, NULL ); }
} }
catch (...) catch (...)
{ {

View File

@@ -54,7 +54,8 @@ DEFINE_GUID(IID_PinnedMemoryAllocator,
namespace namespace
{ {
constexpr GLuint kVideoTextureUnit = 1; constexpr GLuint kDecodedVideoTextureUnit = 1;
constexpr GLuint kPackedVideoTextureUnit = 2;
constexpr GLuint kGlobalParamsBindingPoint = 0; constexpr GLuint kGlobalParamsBindingPoint = 0;
const char* kDisplayModeName = "1080p59.94"; const char* kDisplayModeName = "1080p59.94";
const char* kVertexShaderSource = const char* kVertexShaderSource =
@@ -67,6 +68,31 @@ const char* kVertexShaderSource =
" gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);\n" " gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);\n"
" vTexCoord = texCoords[gl_VertexID];\n" " vTexCoord = texCoords[gl_VertexID];\n"
"}\n"; "}\n";
const char* kDecodeFragmentShaderSource =
"#version 430 core\n"
"layout(binding = 2) uniform sampler2D uPackedVideoInput;\n"
"uniform vec2 uPackedVideoResolution;\n"
"uniform vec2 uDecodedVideoResolution;\n"
"in vec2 vTexCoord;\n"
"layout(location = 0) out vec4 fragColor;\n"
"vec4 rec709YCbCr2rgba(float Y, float Cb, float Cr, float a)\n"
"{\n"
" Y = (Y * 256.0 - 16.0) / 219.0;\n"
" Cb = (Cb * 256.0 - 16.0) / 224.0 - 0.5;\n"
" Cr = (Cr * 256.0 - 16.0) / 224.0 - 0.5;\n"
" return vec4(Y + 1.5748 * Cr, Y - 0.1873 * Cb - 0.4681 * Cr, Y + 1.8556 * Cb, a);\n"
"}\n"
"void main()\n"
"{\n"
" vec2 correctedUv = vec2(vTexCoord.x, 1.0 - vTexCoord.y);\n"
" ivec2 decodedSize = ivec2(max(uDecodedVideoResolution, vec2(1.0, 1.0)));\n"
" ivec2 outputCoord = clamp(ivec2(correctedUv * vec2(decodedSize)), ivec2(0, 0), decodedSize - ivec2(1, 1));\n"
" ivec2 packedSize = ivec2(max(uPackedVideoResolution, vec2(1.0, 1.0)));\n"
" ivec2 packedCoord = ivec2(clamp(outputCoord.x / 2, 0, packedSize.x - 1), clamp(outputCoord.y, 0, packedSize.y - 1));\n"
" vec4 macroPixel = texelFetch(uPackedVideoInput, packedCoord, 0);\n"
" float ySample = (outputCoord.x & 1) != 0 ? macroPixel.a : macroPixel.g;\n"
" fragColor = rec709YCbCr2rgba(ySample, macroPixel.b, macroPixel.r, 1.0);\n"
"}\n";
void CopyErrorMessage(const std::string& message, int errorMessageSize, char* errorMessage) void CopyErrorMessage(const std::string& message, int errorMessageSize, char* errorMessage)
{ {
@@ -129,9 +155,14 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) :
mHasNoInputSource(true), mHasNoInputSource(true),
mFastTransferExtensionAvailable(false), mFastTransferExtensionAvailable(false),
mCaptureTexture(0), mCaptureTexture(0),
mDecodedTexture(0),
mFBOTexture(0), mFBOTexture(0),
mDecodeFrameBuf(0),
mFullscreenVAO(0), mFullscreenVAO(0),
mGlobalParamsUBO(0), mGlobalParamsUBO(0),
mDecodeProgram(0),
mDecodeVertexShader(0),
mDecodeFragmentShader(0),
mProgram(0), mProgram(0),
mVertexShader(0), mVertexShader(0),
mFragmentShader(0), mFragmentShader(0),
@@ -195,6 +226,8 @@ OpenGLComposite::~OpenGLComposite()
glDeleteVertexArrays(1, &mFullscreenVAO); glDeleteVertexArrays(1, &mFullscreenVAO);
if (mGlobalParamsUBO != 0) if (mGlobalParamsUBO != 0)
glDeleteBuffers(1, &mGlobalParamsUBO); glDeleteBuffers(1, &mGlobalParamsUBO);
if (mDecodeFrameBuf != 0)
glDeleteFramebuffers(1, &mDecodeFrameBuf);
if (mIdFrameBuf != 0) if (mIdFrameBuf != 0)
glDeleteFramebuffers(1, &mIdFrameBuf); glDeleteFramebuffers(1, &mIdFrameBuf);
if (mIdColorBuf != 0) if (mIdColorBuf != 0)
@@ -203,12 +236,15 @@ OpenGLComposite::~OpenGLComposite()
glDeleteRenderbuffers(1, &mIdDepthBuf); glDeleteRenderbuffers(1, &mIdDepthBuf);
if (mCaptureTexture != 0) if (mCaptureTexture != 0)
glDeleteTextures(1, &mCaptureTexture); glDeleteTextures(1, &mCaptureTexture);
if (mDecodedTexture != 0)
glDeleteTextures(1, &mDecodedTexture);
if (mFBOTexture != 0) if (mFBOTexture != 0)
glDeleteTextures(1, &mFBOTexture); glDeleteTextures(1, &mFBOTexture);
if (mUnpinnedTextureBuffer != 0) if (mUnpinnedTextureBuffer != 0)
glDeleteBuffers(1, &mUnpinnedTextureBuffer); glDeleteBuffers(1, &mUnpinnedTextureBuffer);
destroyShaderProgram(); destroyShaderProgram();
destroyDecodeShaderProgram();
if (mControlServer) if (mControlServer)
mControlServer->Stop(); mControlServer->Stop();
@@ -421,18 +457,52 @@ error:
void OpenGLComposite::paintGL() void OpenGLComposite::paintGL()
{ {
if (!TryEnterCriticalSection(&pMutex))
{
ValidateRect(hGLWnd, NULL);
return;
}
// The DeckLink API provides IDeckLinkGLScreenPreviewHelper as a convenient way to view the playout video frames // The DeckLink API provides IDeckLinkGLScreenPreviewHelper as a convenient way to view the playout video frames
// in a window. However, it performs a copy from host memory to the GPU which is wasteful in this case since // in a window. However, it performs a copy from host memory to the GPU which is wasteful in this case since
// we already have the rendered frame to be played out sitting in the GPU in the mIdFrameBuf frame buffer. // we already have the rendered frame to be played out sitting in the GPU in the mIdFrameBuf frame buffer.
// Simply copy the off-screen frame buffer to on-screen frame buffer, scaling to the viewing window size. // Copy the off-screen frame buffer to the on-screen frame buffer while preserving the
// incoming video aspect ratio. Any extra window area is cleared to black.
int destWidth = mViewWidth;
int destHeight = mViewHeight;
int destX = 0;
int destY = 0;
if (mFrameWidth > 0 && mFrameHeight > 0 && mViewWidth > 0 && mViewHeight > 0)
{
const double frameAspect = static_cast<double>(mFrameWidth) / static_cast<double>(mFrameHeight);
const double viewAspect = static_cast<double>(mViewWidth) / static_cast<double>(mViewHeight);
if (viewAspect > frameAspect)
{
destHeight = mViewHeight;
destWidth = static_cast<int>(destHeight * frameAspect + 0.5);
destX = (mViewWidth - destWidth) / 2;
}
else
{
destWidth = mViewWidth;
destHeight = static_cast<int>(destWidth / frameAspect + 0.5);
destY = (mViewHeight - destHeight) / 2;
}
}
glBindFramebuffer(GL_READ_FRAMEBUFFER, mIdFrameBuf); glBindFramebuffer(GL_READ_FRAMEBUFFER, mIdFrameBuf);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glViewport(0, 0, mViewWidth, mViewHeight); glViewport(0, 0, mViewWidth, mViewHeight);
glBlitFramebuffer(0, 0, mFrameWidth, mFrameHeight, 0, 0, mViewWidth, mViewHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR); glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glBlitFramebuffer(0, 0, mFrameWidth, mFrameHeight, destX, destY, destX + destWidth, destY + destHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR);
SwapBuffers(hGLDC); SwapBuffers(hGLDC);
ValidateRect(hGLWnd, NULL); ValidateRect(hGLWnd, NULL);
LeaveCriticalSection(&pMutex);
} }
void OpenGLComposite::resizeGL(WORD width, WORD height) void OpenGLComposite::resizeGL(WORD width, WORD height)
@@ -470,6 +540,7 @@ bool OpenGLComposite::InitOpenGLState()
callbacks.updateParameter = [this](const std::string& shaderId, const std::string& parameterId, const std::string& valueJson, std::string& error) { callbacks.updateParameter = [this](const std::string& shaderId, const std::string& parameterId, const std::string& valueJson, std::string& error) {
return UpdateParameterJson(shaderId, parameterId, valueJson, error); return UpdateParameterJson(shaderId, parameterId, valueJson, error);
}; };
callbacks.resetParameters = [this](const std::string& shaderId, std::string& error) { return ResetShaderParameters(shaderId, error); };
callbacks.setBypass = [this](bool bypassEnabled, std::string& error) { return SetBypassEnabled(bypassEnabled, error); }; callbacks.setBypass = [this](bool bypassEnabled, std::string& error) { return SetBypassEnabled(bypassEnabled, error); };
callbacks.setMixAmount = [this](double mixAmount, std::string& error) { return SetMixAmount(mixAmount, error); }; callbacks.setMixAmount = [this](double mixAmount, std::string& error) { return SetMixAmount(mixAmount, error); };
callbacks.reloadShader = [this](std::string& error) { callbacks.reloadShader = [this](std::string& error) {
@@ -490,6 +561,12 @@ bool OpenGLComposite::InitOpenGLState()
// Prepare the runtime shader program generated from the active shader package. // Prepare the runtime shader program generated from the active shader package.
char compilerErrorMessage[1024]; char compilerErrorMessage[1024];
if (! compileDecodeShader(sizeof(compilerErrorMessage), compilerErrorMessage))
{
MessageBoxA(NULL, compilerErrorMessage, "OpenGL decode shader failed to load or compile", MB_OK);
return false;
}
if (! compileFragmentShader(sizeof(compilerErrorMessage), compilerErrorMessage)) if (! compileFragmentShader(sizeof(compilerErrorMessage), compilerErrorMessage))
{ {
MessageBoxA(NULL, compilerErrorMessage, "OpenGL shader failed to load or compile", MB_OK); MessageBoxA(NULL, compilerErrorMessage, "OpenGL shader failed to load or compile", MB_OK);
@@ -520,15 +597,34 @@ bool OpenGLComposite::InitOpenGLState()
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth/2, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth/2, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glBindTexture(GL_TEXTURE_2D, 0); glBindTexture(GL_TEXTURE_2D, 0);
glGenTextures(1, &mDecodedTexture);
glBindTexture(GL_TEXTURE_2D, mDecodedTexture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glBindTexture(GL_TEXTURE_2D, 0);
// Create Frame Buffer Object (FBO) to perform off-screen rendering of scene. // Create Frame Buffer Object (FBO) to perform off-screen rendering of scene.
// This allows the render to be done on a framebuffer with width and height exactly matching the video format. // This allows the render to be done on a framebuffer with width and height exactly matching the video format.
glGenFramebuffers(1, &mDecodeFrameBuf);
glGenFramebuffers(1, &mIdFrameBuf); glGenFramebuffers(1, &mIdFrameBuf);
glGenRenderbuffers(1, &mIdColorBuf); glGenRenderbuffers(1, &mIdColorBuf);
glGenRenderbuffers(1, &mIdDepthBuf); glGenRenderbuffers(1, &mIdDepthBuf);
glGenVertexArrays(1, &mFullscreenVAO); glGenVertexArrays(1, &mFullscreenVAO);
glGenBuffers(1, &mGlobalParamsUBO); glGenBuffers(1, &mGlobalParamsUBO);
glBindFramebuffer(GL_FRAMEBUFFER, mDecodeFrameBuf);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mDecodedTexture, 0);
GLenum glStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (glStatus != GL_FRAMEBUFFER_COMPLETE)
{
MessageBox(NULL, _T("Cannot initialize decode framebuffer."), _T("OpenGL initialization error."), MB_OK);
return false;
}
glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf); glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf);
// Texture for FBO // Texture for FBO
@@ -549,7 +645,7 @@ bool OpenGLComposite::InitOpenGLState()
// Attach the texture which stores the playback image // Attach the texture which stores the playback image
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mFBOTexture, 0); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mFBOTexture, 0);
GLenum glStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER); glStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (glStatus != GL_FRAMEBUFFER_COMPLETE) if (glStatus != GL_FRAMEBUFFER_COMPLETE)
{ {
MessageBox(NULL, _T("Cannot initialize framebuffer."), _T("OpenGL initialization error."), MB_OK); MessageBox(NULL, _T("Cannot initialize framebuffer."), _T("OpenGL initialization error."), MB_OK);
@@ -645,8 +741,18 @@ void OpenGLComposite::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame,
wglMakeCurrent( hGLDC, hGLRC ); wglMakeCurrent( hGLDC, hGLRC );
// Draw the effect output to the off-screen framebuffer. // Draw the effect output to the off-screen framebuffer.
const auto renderStartTime = std::chrono::steady_clock::now();
glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf); glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf);
renderEffect(); renderEffect();
const auto renderEndTime = std::chrono::steady_clock::now();
if (mRuntimeHost)
{
const double frameBudgetMilliseconds = mFrameTimescale != 0
? (static_cast<double>(mFrameDuration) * 1000.0) / static_cast<double>(mFrameTimescale)
: 0.0;
const double renderMilliseconds = std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(renderEndTime - renderStartTime).count();
mRuntimeHost->SetPerformanceStats(frameBudgetMilliseconds, renderMilliseconds);
}
if (mRuntimeHost) if (mRuntimeHost)
mRuntimeHost->AdvanceFrame(); mRuntimeHost->AdvanceFrame();
@@ -789,6 +895,58 @@ bool OpenGLComposite::ReloadShader()
return success; return success;
} }
bool OpenGLComposite::compileDecodeShader(int errorMessageSize, char* errorMessage)
{
GLsizei errorBufferSize = 0;
GLint compileResult = GL_FALSE;
GLint linkResult = GL_FALSE;
const char* vertexSource = kVertexShaderSource;
const char* fragmentSource = kDecodeFragmentShaderSource;
GLuint newVertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(newVertexShader, 1, (const GLchar**)&vertexSource, NULL);
glCompileShader(newVertexShader);
glGetShaderiv(newVertexShader, GL_COMPILE_STATUS, &compileResult);
if (compileResult == GL_FALSE)
{
glGetShaderInfoLog(newVertexShader, errorMessageSize, &errorBufferSize, errorMessage);
glDeleteShader(newVertexShader);
return false;
}
GLuint newFragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(newFragmentShader, 1, (const GLchar**)&fragmentSource, NULL);
glCompileShader(newFragmentShader);
glGetShaderiv(newFragmentShader, GL_COMPILE_STATUS, &compileResult);
if (compileResult == GL_FALSE)
{
glGetShaderInfoLog(newFragmentShader, errorMessageSize, &errorBufferSize, errorMessage);
glDeleteShader(newVertexShader);
glDeleteShader(newFragmentShader);
return false;
}
GLuint newProgram = glCreateProgram();
glAttachShader(newProgram, newVertexShader);
glAttachShader(newProgram, newFragmentShader);
glLinkProgram(newProgram);
glGetProgramiv(newProgram, GL_LINK_STATUS, &linkResult);
if (linkResult == GL_FALSE)
{
glGetProgramInfoLog(newProgram, errorMessageSize, &errorBufferSize, errorMessage);
glDeleteProgram(newProgram);
glDeleteShader(newVertexShader);
glDeleteShader(newFragmentShader);
return false;
}
destroyDecodeShaderProgram();
mDecodeProgram = newProgram;
mDecodeVertexShader = newVertexShader;
mDecodeFragmentShader = newFragmentShader;
return true;
}
void OpenGLComposite::destroyShaderProgram() void OpenGLComposite::destroyShaderProgram()
{ {
if (mProgram != 0) if (mProgram != 0)
@@ -810,13 +968,31 @@ void OpenGLComposite::destroyShaderProgram()
} }
} }
void OpenGLComposite::destroyDecodeShaderProgram()
{
if (mDecodeProgram != 0)
{
glDeleteProgram(mDecodeProgram);
mDecodeProgram = 0;
}
if (mDecodeFragmentShader != 0)
{
glDeleteShader(mDecodeFragmentShader);
mDecodeFragmentShader = 0;
}
if (mDecodeVertexShader != 0)
{
glDeleteShader(mDecodeVertexShader);
mDecodeVertexShader = 0;
}
}
void OpenGLComposite::renderEffect() void OpenGLComposite::renderEffect()
{ {
PollRuntimeChanges(); PollRuntimeChanges();
glViewport(0, 0, mFrameWidth, mFrameHeight);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
if (mHasNoInputSource) if (mHasNoInputSource)
return; return;
@@ -828,8 +1004,13 @@ void OpenGLComposite::renderEffect()
glDisable(GL_BLEND); glDisable(GL_BLEND);
glDisable(GL_DEPTH_TEST); glDisable(GL_DEPTH_TEST);
glActiveTexture(GL_TEXTURE0 + kVideoTextureUnit); renderDecodePass();
glBindTexture(GL_TEXTURE_2D, mCaptureTexture);
glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf);
glViewport(0, 0, mFrameWidth, mFrameHeight);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0 + kDecodedVideoTextureUnit);
glBindTexture(GL_TEXTURE_2D, mDecodedTexture);
glBindVertexArray(mFullscreenVAO); glBindVertexArray(mFullscreenVAO);
glUseProgram(mProgram); glUseProgram(mProgram);
@@ -850,6 +1031,31 @@ void OpenGLComposite::renderEffect()
VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU); VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU);
} }
void OpenGLComposite::renderDecodePass()
{
glBindFramebuffer(GL_FRAMEBUFFER, mDecodeFrameBuf);
glViewport(0, 0, mFrameWidth, mFrameHeight);
glClear(GL_COLOR_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0 + kPackedVideoTextureUnit);
glBindTexture(GL_TEXTURE_2D, mCaptureTexture);
glBindVertexArray(mFullscreenVAO);
glUseProgram(mDecodeProgram);
const GLint packedResolutionLocation = glGetUniformLocation(mDecodeProgram, "uPackedVideoResolution");
const GLint decodedResolutionLocation = glGetUniformLocation(mDecodeProgram, "uDecodedVideoResolution");
if (packedResolutionLocation >= 0)
glUniform2f(packedResolutionLocation, static_cast<float>(mFrameWidth / 2), static_cast<float>(mFrameHeight));
if (decodedResolutionLocation >= 0)
glUniform2f(decodedResolutionLocation, static_cast<float>(mFrameWidth), static_cast<float>(mFrameHeight));
glDrawArrays(GL_TRIANGLES, 0, 3);
glUseProgram(0);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
glActiveTexture(GL_TEXTURE0);
}
// Compile a fullscreen shader pass from the runtime Slang source into a core-profile // Compile a fullscreen shader pass from the runtime Slang source into a core-profile
// GLSL program. The renderer owns the fullscreen pass and parameter UBO layout. // GLSL program. The renderer owns the fullscreen pass and parameter UBO layout.
bool OpenGLComposite::compileFragmentShader(int errorMessageSize, char* errorMessage) bool OpenGLComposite::compileFragmentShader(int errorMessageSize, char* errorMessage)
@@ -1068,6 +1274,15 @@ bool OpenGLComposite::UpdateParameterJson(const std::string& shaderId, const std
return true; return true;
} }
bool OpenGLComposite::ResetShaderParameters(const std::string& shaderId, std::string& error)
{
if (!mRuntimeHost->ResetParameters(shaderId, error))
return false;
broadcastRuntimeState();
return true;
}
bool OpenGLComposite::SetBypassEnabled(bool bypassEnabled, std::string& error) bool OpenGLComposite::SetBypassEnabled(bool bypassEnabled, std::string& error)
{ {
if (!mRuntimeHost->SetBypass(bypassEnabled, error)) if (!mRuntimeHost->SetBypass(bypassEnabled, error))

View File

@@ -81,6 +81,7 @@ public:
std::string GetRuntimeStateJson() const; std::string GetRuntimeStateJson() const;
bool SelectShader(const std::string& shaderId, std::string& error); bool SelectShader(const std::string& shaderId, std::string& error);
bool UpdateParameterJson(const std::string& shaderId, const std::string& parameterId, const std::string& valueJson, std::string& error); bool UpdateParameterJson(const std::string& shaderId, const std::string& parameterId, const std::string& valueJson, std::string& error);
bool ResetShaderParameters(const std::string& shaderId, std::string& error);
bool SetBypassEnabled(bool bypassEnabled, std::string& error); bool SetBypassEnabled(bool bypassEnabled, std::string& error);
bool SetMixAmount(double mixAmount, std::string& error); bool SetMixAmount(double mixAmount, std::string& error);
@@ -116,13 +117,18 @@ private:
// OpenGL data // OpenGL data
bool mFastTransferExtensionAvailable; bool mFastTransferExtensionAvailable;
GLuint mCaptureTexture; GLuint mCaptureTexture;
GLuint mDecodedTexture;
GLuint mFBOTexture; GLuint mFBOTexture;
GLuint mUnpinnedTextureBuffer; GLuint mUnpinnedTextureBuffer;
GLuint mDecodeFrameBuf;
GLuint mIdFrameBuf; GLuint mIdFrameBuf;
GLuint mIdColorBuf; GLuint mIdColorBuf;
GLuint mIdDepthBuf; GLuint mIdDepthBuf;
GLuint mFullscreenVAO; GLuint mFullscreenVAO;
GLuint mGlobalParamsUBO; GLuint mGlobalParamsUBO;
GLuint mDecodeProgram;
GLuint mDecodeVertexShader;
GLuint mDecodeFragmentShader;
GLuint mProgram; GLuint mProgram;
GLuint mVertexShader; GLuint mVertexShader;
GLuint mFragmentShader; GLuint mFragmentShader;
@@ -134,7 +140,10 @@ private:
bool InitOpenGLState(); bool InitOpenGLState();
bool compileFragmentShader(int errorMessageSize, char* errorMessage); bool compileFragmentShader(int errorMessageSize, char* errorMessage);
bool compileDecodeShader(int errorMessageSize, char* errorMessage);
void destroyShaderProgram(); void destroyShaderProgram();
void destroyDecodeShaderProgram();
void renderDecodePass();
void renderEffect(); void renderEffect();
bool PollRuntimeChanges(); bool PollRuntimeChanges();
void broadcastRuntimeState(); void broadcastRuntimeState();

View File

@@ -149,6 +149,9 @@ RuntimeHost::RuntimeHost()
mHasSignal(false), mHasSignal(false),
mSignalWidth(0), mSignalWidth(0),
mSignalHeight(0), mSignalHeight(0),
mFrameBudgetMilliseconds(0.0),
mRenderMilliseconds(0.0),
mSmoothedRenderMilliseconds(0.0),
mServerPort(8080), mServerPort(8080),
mAutoReloadEnabled(true), mAutoReloadEnabled(true),
mMixAmount(1.0), mMixAmount(1.0),
@@ -332,8 +335,25 @@ bool RuntimeHost::UpdateParameter(const std::string& shaderId, const std::string
return false; return false;
mPersistentState.parameterValuesByShader[shaderId][parameterId] = normalized; mPersistentState.parameterValuesByShader[shaderId][parameterId] = normalized;
if (shaderId == mActiveShaderId)
mReloadRequested = true; return SavePersistentState(error);
}
bool RuntimeHost::ResetParameters(const std::string& shaderId, std::string& error)
{
std::lock_guard<std::mutex> lock(mMutex);
auto shaderIt = mPackagesById.find(shaderId);
if (shaderIt == mPackagesById.end())
{
error = "Unknown shader id: " + shaderId;
return false;
}
std::map<std::string, ShaderParameterValue>& shaderValues = mPersistentState.parameterValuesByShader[shaderId];
shaderValues.clear();
for (const ShaderParameterDefinition& definition : shaderIt->second.parameters)
shaderValues[definition.id] = DefaultValueForDefinition(definition);
return SavePersistentState(error); return SavePersistentState(error);
} }
@@ -376,6 +396,17 @@ void RuntimeHost::SetSignalStatus(bool hasSignal, unsigned width, unsigned heigh
mSignalModeName = modeName; mSignalModeName = modeName;
} }
void RuntimeHost::SetPerformanceStats(double frameBudgetMilliseconds, double renderMilliseconds)
{
std::lock_guard<std::mutex> lock(mMutex);
mFrameBudgetMilliseconds = std::max(frameBudgetMilliseconds, 0.0);
mRenderMilliseconds = std::max(renderMilliseconds, 0.0);
if (mSmoothedRenderMilliseconds <= 0.0)
mSmoothedRenderMilliseconds = mRenderMilliseconds;
else
mSmoothedRenderMilliseconds = mSmoothedRenderMilliseconds * 0.9 + mRenderMilliseconds * 0.1;
}
void RuntimeHost::AdvanceFrame() void RuntimeHost::AdvanceFrame()
{ {
std::lock_guard<std::mutex> lock(mMutex); std::lock_guard<std::mutex> lock(mMutex);
@@ -944,65 +975,17 @@ std::string RuntimeHost::BuildWrapperSlangSource(const ShaderPackage& shaderPack
source << "\t" << SlangTypeForParameter(definition.type).substr(strlen("uniform ")) << " " << definition.id << ";\n"; source << "\t" << SlangTypeForParameter(definition.type).substr(strlen("uniform ")) << " " << definition.id << ";\n";
source << "};\n\n"; source << "};\n\n";
source << "Sampler2D<float4> gVideoInput;\n\n"; source << "Sampler2D<float4> gVideoInput;\n\n";
source << "float4 rec709YCbCr2rgba(float Y, float Cb, float Cr, float a)\n";
source << "{\n";
source << "\tY = (Y * 256.0 - 16.0) / 219.0;\n";
source << "\tCb = (Cb * 256.0 - 16.0) / 224.0 - 0.5;\n";
source << "\tCr = (Cr * 256.0 - 16.0) / 224.0 - 0.5;\n";
source << "\treturn float4(Y + 1.5748 * Cr, Y - 0.1873 * Cb - 0.4681 * Cr, Y + 1.8556 * Cb, a);\n";
source << "}\n\n";
source << "float4 bilinear(float4 W, float4 X, float4 Y, float4 Z, float2 weight)\n";
source << "{\n";
source << "\tfloat4 m0 = lerp(W, Z, weight.x);\n";
source << "\tfloat4 m1 = lerp(X, Y, weight.x);\n";
source << "\treturn lerp(m0, m1, weight.y);\n";
source << "}\n\n";
source << "void textureGatherYUV(Sampler2D<float4> textureSampler, float2 tc, out float4 W, out float4 X, out float4 Y, out float4 Z)\n";
source << "{\n";
source << "\tuint width = 0;\n";
source << "\tuint height = 0;\n";
source << "\ttextureSampler.GetDimensions(width, height);\n";
source << "\tint2 tx = int2(tc * float2(width, height));\n";
source << "\tint2 tmin = int2(0, 0);\n";
source << "\tint2 tmax = int2(int(width), int(height)) - int2(1, 1);\n";
source << "\tW = textureSampler.Load(int3(tx, 0));\n";
source << "\tX = textureSampler.Load(int3(clamp(tx + int2(0, 1), tmin, tmax), 0));\n";
source << "\tY = textureSampler.Load(int3(clamp(tx + int2(1, 1), tmin, tmax), 0));\n";
source << "\tZ = textureSampler.Load(int3(clamp(tx + int2(1, 0), tmin, tmax), 0));\n";
source << "}\n\n";
source << "float4 sampleVideo(float2 tc)\n"; source << "float4 sampleVideo(float2 tc)\n";
source << "{\n"; source << "{\n";
source << "\tfloat4 macro, macroU, macroR, macroUR;\n"; source << "\treturn gVideoInput.Sample(tc);\n";
source << "\ttextureGatherYUV(gVideoInput, tc, macro, macroU, macroUR, macroR);\n";
source << "\tuint width = 0;\n";
source << "\tuint height = 0;\n";
source << "\tgVideoInput.GetDimensions(width, height);\n";
source << "\tfloat2 off = frac(tc * float2(width, height));\n";
source << "\tfloat4 pixel, pixelR, pixelU, pixelUR;\n";
source << "\tif (off.x > 0.5)\n";
source << "\t{\n";
source << "\t\tpixel = rec709YCbCr2rgba(macro.a, macro.b, macro.r, 1.0);\n";
source << "\t\tpixelR = rec709YCbCr2rgba(macroR.g, macroR.b, macroR.r, 1.0);\n";
source << "\t\tpixelU = rec709YCbCr2rgba(macroU.a, macroU.b, macroU.r, 1.0);\n";
source << "\t\tpixelUR = rec709YCbCr2rgba(macroUR.g, macroUR.b, macroUR.r, 1.0);\n";
source << "\t}\n";
source << "\telse\n";
source << "\t{\n";
source << "\t\tpixel = rec709YCbCr2rgba(macro.g, macro.b, macro.r, 1.0);\n";
source << "\t\tpixelR = rec709YCbCr2rgba(macro.a, macro.b, macro.r, 1.0);\n";
source << "\t\tpixelU = rec709YCbCr2rgba(macroU.g, macroU.b, macroU.r, 1.0);\n";
source << "\t\tpixelUR = rec709YCbCr2rgba(macroU.a, macroU.b, macroU.r, 1.0);\n";
source << "\t}\n";
source << "\treturn bilinear(pixel, pixelU, pixelUR, pixelR, off);\n";
source << "}\n\n"; source << "}\n\n";
source << "#include \"" << shaderPackage.shaderPath.generic_string() << "\"\n\n"; source << "#include \"" << shaderPackage.shaderPath.generic_string() << "\"\n\n";
source << "[shader(\"fragment\")]\n"; source << "[shader(\"fragment\")]\n";
source << "float4 fragmentMain(FragmentInput input) : SV_Target\n"; source << "float4 fragmentMain(FragmentInput input) : SV_Target\n";
source << "{\n"; source << "{\n";
source << "\tShaderContext context;\n"; source << "\tShaderContext context;\n";
source << "\tfloat2 correctedUv = float2(input.texCoord.x, 1.0 - input.texCoord.y);\n"; source << "\tcontext.uv = input.texCoord;\n";
source << "\tcontext.uv = correctedUv;\n"; source << "\tcontext.sourceColor = sampleVideo(context.uv);\n";
source << "\tcontext.sourceColor = sampleVideo(correctedUv);\n";
source << "\tcontext.inputResolution = gInputResolution;\n"; source << "\tcontext.inputResolution = gInputResolution;\n";
source << "\tcontext.outputResolution = gOutputResolution;\n"; source << "\tcontext.outputResolution = gOutputResolution;\n";
source << "\tcontext.time = gTime;\n"; source << "\tcontext.time = gTime;\n";
@@ -1184,6 +1167,13 @@ JsonValue RuntimeHost::BuildStateValue() const
video.set("modeName", JsonValue(mSignalModeName)); video.set("modeName", JsonValue(mSignalModeName));
root.set("video", video); root.set("video", video);
JsonValue performance = JsonValue::MakeObject();
performance.set("frameBudgetMs", JsonValue(mFrameBudgetMilliseconds));
performance.set("renderMs", JsonValue(mRenderMilliseconds));
performance.set("smoothedRenderMs", JsonValue(mSmoothedRenderMilliseconds));
performance.set("budgetUsedPercent", JsonValue(mFrameBudgetMilliseconds > 0.0 ? (mSmoothedRenderMilliseconds / mFrameBudgetMilliseconds) * 100.0 : 0.0));
root.set("performance", performance);
JsonValue shaders = JsonValue::MakeArray(); JsonValue shaders = JsonValue::MakeArray();
for (const std::string& shaderId : mPackageOrder) for (const std::string& shaderId : mPackageOrder)
{ {

View File

@@ -88,11 +88,13 @@ public:
bool SelectShader(const std::string& shaderId, std::string& error); bool SelectShader(const std::string& shaderId, std::string& error);
bool UpdateParameter(const std::string& shaderId, const std::string& parameterId, const JsonValue& newValue, std::string& error); bool UpdateParameter(const std::string& shaderId, const std::string& parameterId, const JsonValue& newValue, std::string& error);
bool ResetParameters(const std::string& shaderId, std::string& error);
bool SetBypass(bool bypassEnabled, std::string& error); bool SetBypass(bool bypassEnabled, std::string& error);
bool SetMixAmount(double mixAmount, std::string& error); bool SetMixAmount(double mixAmount, std::string& error);
void SetCompileStatus(bool succeeded, const std::string& message); void SetCompileStatus(bool succeeded, const std::string& message);
void SetSignalStatus(bool hasSignal, unsigned width, unsigned height, const std::string& modeName); void SetSignalStatus(bool hasSignal, unsigned width, unsigned height, const std::string& modeName);
void SetPerformanceStats(double frameBudgetMilliseconds, double renderMilliseconds);
void AdvanceFrame(); void AdvanceFrame();
bool BuildActiveFragmentShaderSource(std::string& fragmentShaderSource, std::string& error); bool BuildActiveFragmentShaderSource(std::string& fragmentShaderSource, std::string& error);
@@ -163,6 +165,9 @@ private:
unsigned mSignalWidth; unsigned mSignalWidth;
unsigned mSignalHeight; unsigned mSignalHeight;
std::string mSignalModeName; std::string mSignalModeName;
double mFrameBudgetMilliseconds;
double mRenderMilliseconds;
double mSmoothedRenderMilliseconds;
unsigned short mServerPort; unsigned short mServerPort;
bool mAutoReloadEnabled; bool mAutoReloadEnabled;
double mMixAmount; double mMixAmount;

View File

@@ -0,0 +1,8 @@
{
"id": "black-and-white",
"name": "Black and White",
"description": "A minimal monochrome shader that converts the decoded video input to grayscale.",
"category": "Built-in",
"entryPoint": "shadeVideo",
"parameters": []
}

View File

@@ -0,0 +1,5 @@
float4 shadeVideo(ShaderContext context)
{
float luma = dot(context.sourceColor.rgb, float3(0.2126, 0.7152, 0.0722));
return float4(luma, luma, luma, context.sourceColor.a);
}

View File

@@ -0,0 +1,36 @@
{
"id": "gaussian-blur",
"name": "Gaussian Blur",
"description": "Applies a simple Gaussian-style blur to the decoded video input.",
"category": "Built-in",
"entryPoint": "shadeVideo",
"parameters": [
{
"id": "radius",
"label": "Radius",
"type": "float",
"default": 2.0,
"min": 0.0,
"max": 8.0,
"step": 0.1
},
{
"id": "strength",
"label": "Strength",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
},
{
"id": "samples",
"label": "Samples",
"type": "float",
"default": 2.0,
"min": 0.0,
"max": 25.0,
"step": 1.0
}
]
}

View File

@@ -0,0 +1,35 @@
float4 shadeVideo(ShaderContext context)
{
float2 texel = 1.0 / max(context.inputResolution, float2(1.0, 1.0));
float blurRadius = max(radius, 0.0);
float2 sampleStep = texel * blurRadius;
int sampleRadius = int(clamp(samples, 0.0, 8.0) + 0.5);
float4 center = sampleVideo(context.uv);
float4 blur = float4(0.0, 0.0, 0.0, 0.0);
float totalWeight = 0.0;
for (int y = -sampleRadius; y <= sampleRadius; ++y)
{
for (int x = -sampleRadius; x <= sampleRadius; ++x)
{
float distanceSquared = float(x * x + y * y);
float sigma = max(float(sampleRadius) * 0.5, 0.5);
float weight = exp(-distanceSquared / (2.0 * sigma * sigma));
float2 offset = float2(float(x), float(y)) * sampleStep;
blur += sampleVideo(context.uv + offset) * weight;
totalWeight += weight;
}
}
if (sampleRadius == 0)
{
blur = center;
totalWeight = 1.0;
}
blur /= max(totalWeight, 0.0001);
float mixValue = saturate(strength);
return lerp(center, blur, mixValue);
}

72
shaders/vhs/shader.json Normal file
View File

@@ -0,0 +1,72 @@
{
"id": "vhs",
"name": "VHS",
"description": "VHS with wiggle, smear, and YIQ-style color separation inspired by the Godot shader reference.",
"category": "Built-in",
"entryPoint": "shadeVideo",
"parameters": [
{
"id": "wiggle",
"label": "Wiggle",
"type": "float",
"default": 0.03,
"min": 0.0,
"max": 1.5,
"step": 0.01
},
{
"id": "wiggleSpeed",
"label": "Wiggle Speed",
"type": "float",
"default": 25.0,
"min": 0.0,
"max": 100.0,
"step": 1.0
},
{
"id": "smear",
"label": "Smear",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.01
},
{
"id": "blurSamples",
"label": "Blur Samples",
"type": "float",
"default": 15.0,
"min": 3.0,
"max": 15.0,
"step": 1.0
},
{
"id": "vignetteAmount",
"label": "Vignette",
"type": "float",
"default": 0.18,
"min": 0.0,
"max": 0.6,
"step": 0.01
},
{
"id": "aberrationAmount",
"label": "Aberration",
"type": "float",
"default": 0.75,
"min": 0.0,
"max": 3.0,
"step": 0.05
},
{
"id": "halationAmount",
"label": "Halation",
"type": "float",
"default": 0.12,
"min": 0.0,
"max": 0.5,
"step": 0.01
}
]
}

115
shaders/vhs/shader.slang Normal file
View File

@@ -0,0 +1,115 @@
float onOff(float a, float b, float c, float framecount)
{
return step(c, sin((framecount * 0.001) + a * cos((framecount * 0.001) * b)));
}
float2 jumpy(float2 uv, float framecount)
{
float2 look = uv;
float m = frac(framecount / 4.0);
float dy = look.y - m;
float window = 1.0 / (1.0 + 80.0 * dy * dy);
look.x += 0.05 * sin(look.y * 10.0 + framecount) / 20.0 * onOff(4.0, 4.0, 0.3, framecount) * (0.5 + cos(framecount * 20.0)) * window;
float vShift = (0.1 * wiggle) * 0.4 * onOff(2.0, 3.0, 0.9, framecount) * (sin(framecount) * sin(framecount * 20.0) + (0.5 + 0.1 * sin(framecount * 200.0) * cos(framecount)));
look.y = frac(look.y - 0.01 * vShift);
return look;
}
float2 circle(float start, float points, float point)
{
float rad = 6.28318530718 * (1.0 / points) * (point + start);
return float2(-(.3 + rad), cos(rad));
}
float3 rgb2yiq(float3 c)
{
return float3(
0.2989 * c.x + 0.5959 * c.y + 0.2115 * c.z,
0.5870 * c.x - 0.2744 * c.y - 0.5229 * c.z,
0.1140 * c.x - 0.3216 * c.y + 0.3114 * c.z
);
}
float3 yiq2rgb(float3 c)
{
return float3(
1.0 * c.x + 1.0 * c.y + 1.0 * c.z,
0.956 * c.x - 0.2720 * c.y - 1.1060 * c.z,
0.6210 * c.x - 0.6474 * c.y + 1.7046 * c.z
);
}
float3 blurVhs(float2 uv, float d, int sampleCount)
{
float3 sum = float3(0.0, 0.0, 0.0);
float weight = 1.0 / max(float(sampleCount), 1.0);
float start = 2.0 / max(float(sampleCount), 1.0);
float2 pixelOffset = float2(d, 0.0);
float2 scale = 0.66 * 8.0 * pixelOffset;
for (int i = 0; i < 15; ++i)
{
if (i >= sampleCount)
break;
float2 offset = circle(start, float(sampleCount), float(i)) * scale;
sum += sampleVideo(frac(uv + offset)).rgb * weight;
}
return sum;
}
float4 shadeVideo(ShaderContext context)
{
float2 uv = context.uv;
float framecount = frac(context.time * wiggleSpeed / 7.0) * 7.0;
int sampleCount = int(clamp(blurSamples, 3.0, 15.0) + 0.5);
float d = 0.1 - round(frac(context.time / 3.0)) * 0.1;
uv = jumpy(uv, framecount);
float s = 0.0001 * -d + 0.0001 * wiggle * sin(context.time * wiggleSpeed);
float e = min(0.30, pow(max(0.0, cos(uv.y * 4.0 + 0.3) - 0.75) * (s + 0.5), 3.0)) * 25.0;
float r = 250.0 * (2.0 * s);
uv.x += abs(r * pow(min(0.003, (-uv.y + (0.01 * frac(context.time / 5.0) * 5.0))) * 3.0, 2.0)) * wiggle;
d = 0.051 + abs(sin(s / 4.0));
float c = max(0.0001, 0.002 * d) * smear;
float3 yBlur = blurVhs(uv, c + c * uv.x, sampleCount);
float y = rgb2yiq(yBlur).r;
uv.x += 0.01 * d;
c *= 6.0;
float3 iBlur = blurVhs(uv, c, sampleCount);
float i = rgb2yiq(iBlur).g;
uv.x += 0.005 * d;
c *= 2.5;
float3 qBlur = blurVhs(uv, c, sampleCount);
float q = rgb2yiq(qBlur).b;
float3 color = yiq2rgb(float3(y, i, q)) - pow(s + e * 2.0, 3.0);
float2 centered = context.uv * 2.0 - 1.0;
centered.x *= context.outputResolution.x / max(context.outputResolution.y, 1.0);
float2 aberrationOffset = centered * (aberrationAmount * 0.0015);
float redAberration = sampleVideo(frac(context.uv + aberrationOffset)).r;
float blueAberration = sampleVideo(frac(context.uv - aberrationOffset)).b;
color.r = lerp(color.r, redAberration, 0.35);
color.b = lerp(color.b, blueAberration, 0.35);
float2 halationOffset = float2(0.0015, 0.0) * (1.0 + smear * 0.35);
float3 halationSource =
sampleVideo(frac(context.uv + halationOffset)).rgb * 0.4 +
sampleVideo(frac(context.uv - halationOffset)).rgb * 0.4 +
sampleVideo(frac(context.uv + halationOffset * 2.0)).rgb * 0.2;
float halationLuma = dot(halationSource, float3(0.299, 0.587, 0.114));
float halationMask = smoothstep(0.45, 1.0, halationLuma) * halationAmount;
color += halationSource * float3(1.0, 0.38, 0.24) * halationMask * 0.35;
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);
return float4(saturate(color), 1.0);
}

View File

@@ -0,0 +1,45 @@
{
"id": "video-cube",
"name": "Video Cube",
"description": "Maps the live video onto the faces of a rotating cube in screen space.",
"category": "Built-in",
"entryPoint": "shadeVideo",
"parameters": [
{
"id": "spinSpeed",
"label": "Spin Speed",
"type": "float",
"default": 1.0,
"min": 0.0,
"max": 4.0,
"step": 0.01
},
{
"id": "cubeScale",
"label": "Cube Scale",
"type": "float",
"default": 0.85,
"min": 0.3,
"max": 1.4,
"step": 0.01
},
{
"id": "faceZoom",
"label": "Face Zoom",
"type": "float",
"default": 1.0,
"min": 0.5,
"max": 2.0,
"step": 0.01
},
{
"id": "backgroundMix",
"label": "Background Mix",
"type": "float",
"default": 0.15,
"min": 0.0,
"max": 1.0,
"step": 0.01
}
]
}

View File

@@ -0,0 +1,116 @@
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);
}
bool intersectCube(float3 rayOrigin, float3 rayDirection, float halfExtent, out float hitDistance)
{
float3 boxMin = float3(-halfExtent, -halfExtent, -halfExtent);
float3 boxMax = float3(halfExtent, halfExtent, halfExtent);
float3 invDir = 1.0 / rayDirection;
float3 t0 = (boxMin - rayOrigin) * invDir;
float3 t1 = (boxMax - rayOrigin) * invDir;
float3 tMin3 = min(t0, t1);
float3 tMax3 = max(t0, t1);
float tMin = max(max(tMin3.x, tMin3.y), tMin3.z);
float tMax = min(min(tMax3.x, tMax3.y), tMax3.z);
if (tMax < max(tMin, 0.0))
{
hitDistance = 0.0;
return false;
}
hitDistance = tMin > 0.0 ? tMin : tMax;
return hitDistance > 0.0;
}
float2 cubeFaceUv(float3 hitPoint, float halfExtent, float zoom)
{
float3 face = abs(hitPoint);
float2 uv = float2(0.5, 0.5);
float safeZoom = max(zoom, 0.001);
if (face.x >= face.y && face.x >= face.z)
{
uv = hitPoint.x > 0.0
? float2(-hitPoint.z, hitPoint.y)
: float2(hitPoint.z, hitPoint.y);
}
else if (face.y >= face.x && face.y >= face.z)
{
uv = hitPoint.y > 0.0
? float2(hitPoint.x, -hitPoint.z)
: float2(hitPoint.x, hitPoint.z);
}
else
{
uv = hitPoint.z > 0.0
? float2(hitPoint.x, hitPoint.y)
: float2(-hitPoint.x, hitPoint.y);
}
uv = uv / (halfExtent * 2.0 * safeZoom) + 0.5;
return frac(uv);
}
float4 shadeVideo(ShaderContext context)
{
float2 centeredUv = context.uv * 2.0 - 1.0;
float aspect = context.outputResolution.x / max(context.outputResolution.y, 1.0);
centeredUv.x *= aspect;
float3 rayOrigin = float3(0.0, 0.0, 2.7);
float3 rayDirection = normalize(float3(centeredUv, -2.1));
float spin = context.time * spinSpeed;
float yaw = spin;
float pitch = spin * 0.61 + 0.35;
float3 localOrigin = rotateY(rotateX(rayOrigin, -pitch), -yaw);
float3 localDirection = rotateY(rotateX(rayDirection, -pitch), -yaw);
float halfExtent = max(cubeScale, 0.05);
float hitDistance = 0.0;
float3 background = lerp(float3(0.02, 0.02, 0.03), context.sourceColor.rgb, saturate(backgroundMix));
if (!intersectCube(localOrigin, localDirection, halfExtent, hitDistance))
return float4(background, 1.0);
float3 localHit = localOrigin + localDirection * hitDistance;
float2 faceUv = cubeFaceUv(localHit, halfExtent, faceZoom);
float4 faceColor = sampleVideo(faceUv);
float3 normal;
float3 face = abs(localHit);
if (face.x >= face.y && face.x >= face.z)
normal = float3(sign(localHit.x), 0.0, 0.0);
else if (face.y >= face.x && face.y >= face.z)
normal = float3(0.0, sign(localHit.y), 0.0);
else
normal = float3(0.0, 0.0, sign(localHit.z));
normal = rotateX(rotateY(normal, yaw), pitch);
float3 lightDir = normalize(float3(0.5, 0.8, 0.6));
float diffuse = saturate(dot(normal, lightDir)) * 0.75 + 0.25;
float edge = 1.0 - saturate(max(face.x, max(face.y, face.z)) - halfExtent + 0.03) / 0.03;
float3 shaded = faceColor.rgb * diffuse;
shaded = lerp(shaded, shaded + 0.12, edge * 0.35);
return float4(saturate(shaded), 1.0);
}

View File

@@ -2,6 +2,7 @@ const shaderSelect = document.getElementById("shader-select");
const mixSlider = document.getElementById("mix-slider"); const mixSlider = document.getElementById("mix-slider");
const bypassToggle = document.getElementById("bypass-toggle"); const bypassToggle = document.getElementById("bypass-toggle");
const reloadButton = document.getElementById("reload-button"); const reloadButton = document.getElementById("reload-button");
const resetParametersButton = document.getElementById("reset-parameters-button");
const runtimeStatus = document.getElementById("runtime-status"); const runtimeStatus = document.getElementById("runtime-status");
const videoStatus = document.getElementById("video-status"); const videoStatus = document.getElementById("video-status");
const compileStatus = document.getElementById("compile-status"); const compileStatus = document.getElementById("compile-status");
@@ -141,6 +142,11 @@ function renderState(state) {
const shaders = state.shaders || []; const shaders = state.shaders || [];
const activeShaderId = state.runtime.activeShaderId; const activeShaderId = state.runtime.activeShaderId;
const activeShader = shaders.find((shader) => shader.id === activeShaderId) || shaders[0]; const activeShader = shaders.find((shader) => shader.id === activeShaderId) || shaders[0];
const performance = state.performance || {};
const frameBudgetMs = Number(performance.frameBudgetMs || 0);
const renderMs = Number(performance.renderMs || 0);
const smoothedRenderMs = Number(performance.smoothedRenderMs || 0);
const budgetUsedPercent = Number(performance.budgetUsedPercent || 0);
shaderSelect.innerHTML = ""; shaderSelect.innerHTML = "";
shaders.forEach((shader) => { shaders.forEach((shader) => {
@@ -156,12 +162,17 @@ function renderState(state) {
mixSlider.value = state.runtime.mixAmount ?? 1; mixSlider.value = state.runtime.mixAmount ?? 1;
bypassToggle.checked = Boolean(state.runtime.bypass); bypassToggle.checked = Boolean(state.runtime.bypass);
compileStatus.textContent = state.runtime.compileMessage || "No compiler output."; compileStatus.textContent = state.runtime.compileMessage || "No compiler output.";
resetParametersButton.disabled = !activeShader || activeShader.parameters.length === 0;
createKv(runtimeStatus, [ createKv(runtimeStatus, [
["Active Shader", activeShader?.name || "None"], ["Active Shader", activeShader?.name || "None"],
["Auto Reload", state.app.autoReload ? "On" : "Off"], ["Auto Reload", state.app.autoReload ? "On" : "Off"],
["Control URL", `http://127.0.0.1:${state.app.serverPort}`], ["Control URL", `http://127.0.0.1:${state.app.serverPort}`],
["Compile Status", state.runtime.compileSucceeded ? "Ready" : "Error"], ["Compile Status", state.runtime.compileSucceeded ? "Ready" : "Error"],
["Render Time", `${renderMs.toFixed(2)} ms`],
["Smoothed Time", `${smoothedRenderMs.toFixed(2)} ms`],
["Frame Budget", `${frameBudgetMs.toFixed(2)} ms`],
["Budget Used", `${budgetUsedPercent.toFixed(1)}%`],
]); ]);
createKv(videoStatus, [ createKv(videoStatus, [
@@ -209,4 +220,13 @@ reloadButton.addEventListener("click", () => {
postJson("/api/reload", {}); postJson("/api/reload", {});
}); });
resetParametersButton.addEventListener("click", () => {
const activeShaderId = appState?.runtime?.activeShaderId;
if (!activeShaderId) {
return;
}
postJson("/api/reset-parameters", { shaderId: activeShaderId });
});
loadInitialState().then(connectWebSocket); loadInitialState().then(connectWebSocket);

View File

@@ -42,6 +42,7 @@
<section class="panel"> <section class="panel">
<div class="panel__header"> <div class="panel__header">
<h2>Parameters</h2> <h2>Parameters</h2>
<button id="reset-parameters-button" type="button">Reset Parameters</button>
</div> </div>
<form id="parameter-form" class="parameter-grid"></form> <form id="parameter-form" class="parameter-grid"></form>
</section> </section>

View File

@@ -55,9 +55,18 @@ body {
} }
.panel__header { .panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.panel__header button {
width: auto;
min-width: 160px;
}
.status-grid { .status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }