diff --git a/SHADER_CONTRACT.md b/SHADER_CONTRACT.md index fd071fb..bf24fa6 100644 --- a/SHADER_CONTRACT.md +++ b/SHADER_CONTRACT.md @@ -26,7 +26,7 @@ Supported parameter types: ## 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: @@ -40,7 +40,7 @@ float4 shadeVideo(ShaderContext context) Available built-ins through `ShaderContext`: - `uv` -- `sourceColor` +- `sourceColor` - the already-decoded full-resolution RGBA video color at `uv` - `inputResolution` - `outputResolution` - `time` @@ -52,4 +52,4 @@ Manifest parameters are exposed to the shader as globals named by their `id`. 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. diff --git a/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp b/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp index 206a371..d582d2d 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/ControlServer.cpp @@ -247,6 +247,12 @@ bool ControlServer::HandleHttpRequest(SOCKET clientSocket, const std::string& re if (shaderId && parameterId && value && mCallbacks.updateParameter) 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") { const JsonValue* bypass = root.find("bypass"); diff --git a/apps/LoopThroughWithOpenGLCompositing/ControlServer.h b/apps/LoopThroughWithOpenGLCompositing/ControlServer.h index 2aacdfb..c0ec059 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ControlServer.h +++ b/apps/LoopThroughWithOpenGLCompositing/ControlServer.h @@ -18,6 +18,7 @@ public: std::function getStateJson; std::function selectShader; std::function updateParameter; + std::function resetParameters; std::function setBypass; std::function setMixAmount; std::function reloadShader; diff --git a/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.cpp b/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.cpp index b7ce753..19877df 100644 --- a/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.cpp @@ -202,6 +202,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) static HGLRC hRC = NULL; // Permenant Rendering context static HDC hDC = NULL; // Private GDI Device context static OpenGLComposite* pOpenGLComposite = NULL; + static bool sInteractiveResize = false; switch (message) { @@ -289,6 +290,21 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) PostQuitMessage(0); break; + case WM_ENTERSIZEMOVE: + sInteractiveResize = true; + break; + + case WM_EXITSIZEMOVE: + sInteractiveResize = false; + if (pOpenGLComposite) + { + RECT clientRect = {}; + if (GetClientRect(hWnd, &clientRect)) + pOpenGLComposite->resizeGL(static_cast(clientRect.right - clientRect.left), static_cast(clientRect.bottom - clientRect.top)); + } + InvalidateRect(hWnd, NULL, FALSE); + break; + case WM_SIZE: try { @@ -301,15 +317,22 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) } break; + case WM_ERASEBKGND: + return 1; + case WM_PAINT: try { - wglMakeCurrent(hDC, hRC); + PAINTSTRUCT paint = {}; + BeginPaint(hWnd, &paint); + EndPaint(hWnd, &paint); - if (pOpenGLComposite) + if (!sInteractiveResize && pOpenGLComposite) + { + wglMakeCurrent(hDC, hRC); pOpenGLComposite->paintGL(); - - wglMakeCurrent( NULL, NULL ); + wglMakeCurrent( NULL, NULL ); + } } catch (...) { diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp index 155a705..001127e 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp @@ -54,7 +54,8 @@ DEFINE_GUID(IID_PinnedMemoryAllocator, namespace { -constexpr GLuint kVideoTextureUnit = 1; +constexpr GLuint kDecodedVideoTextureUnit = 1; +constexpr GLuint kPackedVideoTextureUnit = 2; constexpr GLuint kGlobalParamsBindingPoint = 0; const char* kDisplayModeName = "1080p59.94"; const char* kVertexShaderSource = @@ -67,6 +68,31 @@ const char* kVertexShaderSource = " gl_Position = vec4(positions[gl_VertexID], 0.0, 1.0);\n" " vTexCoord = texCoords[gl_VertexID];\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) { @@ -129,9 +155,14 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) : mHasNoInputSource(true), mFastTransferExtensionAvailable(false), mCaptureTexture(0), + mDecodedTexture(0), mFBOTexture(0), + mDecodeFrameBuf(0), mFullscreenVAO(0), mGlobalParamsUBO(0), + mDecodeProgram(0), + mDecodeVertexShader(0), + mDecodeFragmentShader(0), mProgram(0), mVertexShader(0), mFragmentShader(0), @@ -195,6 +226,8 @@ OpenGLComposite::~OpenGLComposite() glDeleteVertexArrays(1, &mFullscreenVAO); if (mGlobalParamsUBO != 0) glDeleteBuffers(1, &mGlobalParamsUBO); + if (mDecodeFrameBuf != 0) + glDeleteFramebuffers(1, &mDecodeFrameBuf); if (mIdFrameBuf != 0) glDeleteFramebuffers(1, &mIdFrameBuf); if (mIdColorBuf != 0) @@ -203,12 +236,15 @@ OpenGLComposite::~OpenGLComposite() glDeleteRenderbuffers(1, &mIdDepthBuf); if (mCaptureTexture != 0) glDeleteTextures(1, &mCaptureTexture); + if (mDecodedTexture != 0) + glDeleteTextures(1, &mDecodedTexture); if (mFBOTexture != 0) glDeleteTextures(1, &mFBOTexture); if (mUnpinnedTextureBuffer != 0) glDeleteBuffers(1, &mUnpinnedTextureBuffer); destroyShaderProgram(); + destroyDecodeShaderProgram(); if (mControlServer) mControlServer->Stop(); @@ -421,18 +457,52 @@ error: 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 // 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. - // 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(mFrameWidth) / static_cast(mFrameHeight); + const double viewAspect = static_cast(mViewWidth) / static_cast(mViewHeight); + + if (viewAspect > frameAspect) + { + destHeight = mViewHeight; + destWidth = static_cast(destHeight * frameAspect + 0.5); + destX = (mViewWidth - destWidth) / 2; + } + else + { + destWidth = mViewWidth; + destHeight = static_cast(destWidth / frameAspect + 0.5); + destY = (mViewHeight - destHeight) / 2; + } + } + glBindFramebuffer(GL_READ_FRAMEBUFFER, mIdFrameBuf); glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); 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); ValidateRect(hGLWnd, NULL); + LeaveCriticalSection(&pMutex); } 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) { 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.setMixAmount = [this](double mixAmount, std::string& error) { return SetMixAmount(mixAmount, 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. 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)) { 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); 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. // 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); glGenRenderbuffers(1, &mIdColorBuf); glGenRenderbuffers(1, &mIdDepthBuf); glGenVertexArrays(1, &mFullscreenVAO); 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); // Texture for FBO @@ -549,7 +645,7 @@ bool OpenGLComposite::InitOpenGLState() // Attach the texture which stores the playback image 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) { MessageBox(NULL, _T("Cannot initialize framebuffer."), _T("OpenGL initialization error."), MB_OK); @@ -645,8 +741,18 @@ void OpenGLComposite::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame, wglMakeCurrent( hGLDC, hGLRC ); // Draw the effect output to the off-screen framebuffer. + const auto renderStartTime = std::chrono::steady_clock::now(); glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf); renderEffect(); + const auto renderEndTime = std::chrono::steady_clock::now(); + if (mRuntimeHost) + { + const double frameBudgetMilliseconds = mFrameTimescale != 0 + ? (static_cast(mFrameDuration) * 1000.0) / static_cast(mFrameTimescale) + : 0.0; + const double renderMilliseconds = std::chrono::duration_cast>(renderEndTime - renderStartTime).count(); + mRuntimeHost->SetPerformanceStats(frameBudgetMilliseconds, renderMilliseconds); + } if (mRuntimeHost) mRuntimeHost->AdvanceFrame(); @@ -789,6 +895,58 @@ bool OpenGLComposite::ReloadShader() 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() { 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() { PollRuntimeChanges(); - glViewport(0, 0, mFrameWidth, mFrameHeight); - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - if (mHasNoInputSource) return; @@ -828,8 +1004,13 @@ void OpenGLComposite::renderEffect() glDisable(GL_BLEND); glDisable(GL_DEPTH_TEST); - glActiveTexture(GL_TEXTURE0 + kVideoTextureUnit); - glBindTexture(GL_TEXTURE_2D, mCaptureTexture); + renderDecodePass(); + + 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); glUseProgram(mProgram); @@ -850,6 +1031,31 @@ void OpenGLComposite::renderEffect() 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(mFrameWidth / 2), static_cast(mFrameHeight)); + if (decodedResolutionLocation >= 0) + glUniform2f(decodedResolutionLocation, static_cast(mFrameWidth), static_cast(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 // GLSL program. The renderer owns the fullscreen pass and parameter UBO layout. bool OpenGLComposite::compileFragmentShader(int errorMessageSize, char* errorMessage) @@ -1068,6 +1274,15 @@ bool OpenGLComposite::UpdateParameterJson(const std::string& shaderId, const std 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) { if (!mRuntimeHost->SetBypass(bypassEnabled, error)) diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h index aade1da..3699113 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h @@ -81,6 +81,7 @@ public: std::string GetRuntimeStateJson() const; 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 ResetShaderParameters(const std::string& shaderId, std::string& error); bool SetBypassEnabled(bool bypassEnabled, std::string& error); bool SetMixAmount(double mixAmount, std::string& error); @@ -116,13 +117,18 @@ private: // OpenGL data bool mFastTransferExtensionAvailable; GLuint mCaptureTexture; + GLuint mDecodedTexture; GLuint mFBOTexture; GLuint mUnpinnedTextureBuffer; + GLuint mDecodeFrameBuf; GLuint mIdFrameBuf; GLuint mIdColorBuf; GLuint mIdDepthBuf; GLuint mFullscreenVAO; GLuint mGlobalParamsUBO; + GLuint mDecodeProgram; + GLuint mDecodeVertexShader; + GLuint mDecodeFragmentShader; GLuint mProgram; GLuint mVertexShader; GLuint mFragmentShader; @@ -134,7 +140,10 @@ private: bool InitOpenGLState(); bool compileFragmentShader(int errorMessageSize, char* errorMessage); + bool compileDecodeShader(int errorMessageSize, char* errorMessage); void destroyShaderProgram(); + void destroyDecodeShaderProgram(); + void renderDecodePass(); void renderEffect(); bool PollRuntimeChanges(); void broadcastRuntimeState(); diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp index dee76bd..f8130c5 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp @@ -149,6 +149,9 @@ RuntimeHost::RuntimeHost() mHasSignal(false), mSignalWidth(0), mSignalHeight(0), + mFrameBudgetMilliseconds(0.0), + mRenderMilliseconds(0.0), + mSmoothedRenderMilliseconds(0.0), mServerPort(8080), mAutoReloadEnabled(true), mMixAmount(1.0), @@ -332,8 +335,25 @@ bool RuntimeHost::UpdateParameter(const std::string& shaderId, const std::string return false; 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 lock(mMutex); + + auto shaderIt = mPackagesById.find(shaderId); + if (shaderIt == mPackagesById.end()) + { + error = "Unknown shader id: " + shaderId; + return false; + } + + std::map& shaderValues = mPersistentState.parameterValuesByShader[shaderId]; + shaderValues.clear(); + for (const ShaderParameterDefinition& definition : shaderIt->second.parameters) + shaderValues[definition.id] = DefaultValueForDefinition(definition); return SavePersistentState(error); } @@ -376,6 +396,17 @@ void RuntimeHost::SetSignalStatus(bool hasSignal, unsigned width, unsigned heigh mSignalModeName = modeName; } +void RuntimeHost::SetPerformanceStats(double frameBudgetMilliseconds, double renderMilliseconds) +{ + std::lock_guard 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() { std::lock_guard 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 << "};\n\n"; source << "Sampler2D 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 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 << "{\n"; - source << "\tfloat4 macro, macroU, macroR, macroUR;\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 << "\treturn gVideoInput.Sample(tc);\n"; source << "}\n\n"; source << "#include \"" << shaderPackage.shaderPath.generic_string() << "\"\n\n"; source << "[shader(\"fragment\")]\n"; source << "float4 fragmentMain(FragmentInput input) : SV_Target\n"; source << "{\n"; source << "\tShaderContext context;\n"; - source << "\tfloat2 correctedUv = float2(input.texCoord.x, 1.0 - input.texCoord.y);\n"; - source << "\tcontext.uv = correctedUv;\n"; - source << "\tcontext.sourceColor = sampleVideo(correctedUv);\n"; + source << "\tcontext.uv = input.texCoord;\n"; + source << "\tcontext.sourceColor = sampleVideo(context.uv);\n"; source << "\tcontext.inputResolution = gInputResolution;\n"; source << "\tcontext.outputResolution = gOutputResolution;\n"; source << "\tcontext.time = gTime;\n"; @@ -1184,6 +1167,13 @@ JsonValue RuntimeHost::BuildStateValue() const video.set("modeName", JsonValue(mSignalModeName)); 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(); for (const std::string& shaderId : mPackageOrder) { diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h index f15a66c..3d329c2 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.h @@ -88,11 +88,13 @@ public: 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 ResetParameters(const std::string& shaderId, std::string& error); bool SetBypass(bool bypassEnabled, std::string& error); bool SetMixAmount(double mixAmount, std::string& error); void SetCompileStatus(bool succeeded, const std::string& message); void SetSignalStatus(bool hasSignal, unsigned width, unsigned height, const std::string& modeName); + void SetPerformanceStats(double frameBudgetMilliseconds, double renderMilliseconds); void AdvanceFrame(); bool BuildActiveFragmentShaderSource(std::string& fragmentShaderSource, std::string& error); @@ -163,6 +165,9 @@ private: unsigned mSignalWidth; unsigned mSignalHeight; std::string mSignalModeName; + double mFrameBudgetMilliseconds; + double mRenderMilliseconds; + double mSmoothedRenderMilliseconds; unsigned short mServerPort; bool mAutoReloadEnabled; double mMixAmount; diff --git a/shaders/black-and-white/shader.json b/shaders/black-and-white/shader.json new file mode 100644 index 0000000..1e8a4d0 --- /dev/null +++ b/shaders/black-and-white/shader.json @@ -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": [] +} diff --git a/shaders/black-and-white/shader.slang b/shaders/black-and-white/shader.slang new file mode 100644 index 0000000..11ffa05 --- /dev/null +++ b/shaders/black-and-white/shader.slang @@ -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); +} diff --git a/shaders/gaussian-blur/shader.json b/shaders/gaussian-blur/shader.json new file mode 100644 index 0000000..d51826b --- /dev/null +++ b/shaders/gaussian-blur/shader.json @@ -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 + } + ] +} diff --git a/shaders/gaussian-blur/shader.slang b/shaders/gaussian-blur/shader.slang new file mode 100644 index 0000000..606b176 --- /dev/null +++ b/shaders/gaussian-blur/shader.slang @@ -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); +} diff --git a/shaders/vhs/shader.json b/shaders/vhs/shader.json new file mode 100644 index 0000000..ad252d2 --- /dev/null +++ b/shaders/vhs/shader.json @@ -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 + } + ] +} diff --git a/shaders/vhs/shader.slang b/shaders/vhs/shader.slang new file mode 100644 index 0000000..f6d1925 --- /dev/null +++ b/shaders/vhs/shader.slang @@ -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); +} diff --git a/shaders/video-cube/shader.json b/shaders/video-cube/shader.json new file mode 100644 index 0000000..6c7713b --- /dev/null +++ b/shaders/video-cube/shader.json @@ -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 + } + ] +} diff --git a/shaders/video-cube/shader.slang b/shaders/video-cube/shader.slang new file mode 100644 index 0000000..17e82b1 --- /dev/null +++ b/shaders/video-cube/shader.slang @@ -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); +} diff --git a/ui/app.js b/ui/app.js index 323ccc0..d8338fb 100644 --- a/ui/app.js +++ b/ui/app.js @@ -2,6 +2,7 @@ const shaderSelect = document.getElementById("shader-select"); const mixSlider = document.getElementById("mix-slider"); const bypassToggle = document.getElementById("bypass-toggle"); const reloadButton = document.getElementById("reload-button"); +const resetParametersButton = document.getElementById("reset-parameters-button"); const runtimeStatus = document.getElementById("runtime-status"); const videoStatus = document.getElementById("video-status"); const compileStatus = document.getElementById("compile-status"); @@ -141,6 +142,11 @@ function renderState(state) { const shaders = state.shaders || []; const activeShaderId = state.runtime.activeShaderId; 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 = ""; shaders.forEach((shader) => { @@ -156,12 +162,17 @@ function renderState(state) { mixSlider.value = state.runtime.mixAmount ?? 1; bypassToggle.checked = Boolean(state.runtime.bypass); compileStatus.textContent = state.runtime.compileMessage || "No compiler output."; + resetParametersButton.disabled = !activeShader || activeShader.parameters.length === 0; createKv(runtimeStatus, [ ["Active Shader", activeShader?.name || "None"], ["Auto Reload", state.app.autoReload ? "On" : "Off"], ["Control URL", `http://127.0.0.1:${state.app.serverPort}`], ["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, [ @@ -209,4 +220,13 @@ reloadButton.addEventListener("click", () => { postJson("/api/reload", {}); }); +resetParametersButton.addEventListener("click", () => { + const activeShaderId = appState?.runtime?.activeShaderId; + if (!activeShaderId) { + return; + } + + postJson("/api/reset-parameters", { shaderId: activeShaderId }); +}); + loadInitialState().then(connectWebSocket); diff --git a/ui/index.html b/ui/index.html index 48d7bcc..f5c6a82 100644 --- a/ui/index.html +++ b/ui/index.html @@ -42,6 +42,7 @@

Parameters

+
diff --git a/ui/styles.css b/ui/styles.css index 74e612b..a67fadc 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -55,9 +55,18 @@ body { } .panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; margin-bottom: 12px; } +.panel__header button { + width: auto; + min-width: 160px; +} + .status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }