Repository: iyinchao/liquid-glass-studio Branch: main Commit: f8c39522622c Files: 76 Total size: 267.6 KB Directory structure: gitextract_f39ysz36/ ├── .editorconfig ├── .gitignore ├── .prettierrc.js ├── LICENSE ├── README-uz.md ├── README-zh.md ├── README.md ├── eslint.config.js ├── index.html ├── openspec/ │ ├── changes/ │ │ ├── add-webgpu-backend/ │ │ │ ├── design.md │ │ │ ├── proposal.md │ │ │ ├── specs/ │ │ │ │ ├── glass-rendering/ │ │ │ │ │ └── spec.md │ │ │ │ └── parameter-controls/ │ │ │ │ └── spec.md │ │ │ └── tasks.md │ │ └── archive/ │ │ └── 2026-03-23-add-initial-specs/ │ │ ├── proposal.md │ │ ├── specs/ │ │ │ ├── background-system/ │ │ │ │ └── spec.md │ │ │ ├── glass-rendering/ │ │ │ │ └── spec.md │ │ │ ├── parameter-controls/ │ │ │ │ └── spec.md │ │ │ ├── preset-management/ │ │ │ │ └── spec.md │ │ │ └── shape-system/ │ │ │ └── spec.md │ │ └── tasks.md │ ├── project.md │ └── specs/ │ ├── background-system/ │ │ └── spec.md │ ├── glass-rendering/ │ │ └── spec.md │ ├── parameter-controls/ │ │ └── spec.md │ ├── preset-management/ │ │ └── spec.md │ └── shape-system/ │ └── spec.md ├── package.json ├── src/ │ ├── App.module.scss │ ├── App.tsx │ ├── Controls.tsx │ ├── components/ │ │ ├── LevaButton/ │ │ │ ├── LevaButton.scss │ │ │ ├── LevaButton.tsx │ │ │ └── index.ts │ │ ├── LevaCheckButtons/ │ │ │ ├── LevaCheckButtons.scss │ │ │ ├── LevaCheckButtons.tsx │ │ │ └── index.ts │ │ ├── LevaContainer/ │ │ │ ├── LevaContainer.scss │ │ │ ├── LevaContainer.tsx │ │ │ └── index.ts │ │ ├── LevaImageUpload/ │ │ │ ├── LevaImageUpload.scss │ │ │ └── LevaImageUpload.tsx │ │ ├── LevaVectorNew/ │ │ │ ├── LevaVectorNew.scss │ │ │ └── LevaVectorNew.tsx │ │ ├── PresetControls/ │ │ │ ├── PresetControls.module.scss │ │ │ └── PresetControls.tsx │ │ └── ResizableWindow/ │ │ ├── ResizableWindow.module.scss │ │ ├── ResizableWindow.tsx │ │ └── index.tsx │ ├── index.scss │ ├── main.tsx │ ├── shaders/ │ │ ├── fragment-bg-hblur.glsl │ │ ├── fragment-bg-vblur.glsl │ │ ├── fragment-bg.glsl │ │ ├── fragment-main-1.glsl │ │ ├── fragment-main.glsl │ │ ├── test.glsl │ │ └── vertex.glsl │ ├── shaders-wgsl/ │ │ ├── fragment-bg-hblur.wgsl │ │ ├── fragment-bg-vblur.wgsl │ │ ├── fragment-bg.wgsl │ │ ├── fragment-main.wgsl │ │ └── vertex.wgsl │ ├── utils/ │ │ ├── GLUtils.ts │ │ ├── GPUUtils.ts │ │ ├── RendererInterface.ts │ │ ├── gpuDetect.ts │ │ ├── index.ts │ │ ├── languages.ts │ │ ├── presetUtils.ts │ │ └── useResizeOberver.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.{md,mdx}] trim_trailing_whitespace = false ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? # Claude Code .claude/ CLAUDE.md AGENTS.md ================================================ FILE: .prettierrc.js ================================================ export default { printWidth: 100, singleQuote: true, plugins: ['prettier-plugin-glsl'], }; ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Charles Yin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README-uz.md ================================================ # 🔮 Liquid Glass Studio ![frontPhoto](./.github/assets/title.png) [English](README.md) | [简体中文](README-zh.md) WebGL2 & WebGPU va shaderlar asosida yaratilgan Apple’ning Liquid Glass (suyuq shisha) effektining to’liq veb talqini. Sozlanadigan parametrlar orqali siz barcha “liquid glass” effektlarini sinab ko‘rishingiz mumkin. ## Online Demo https://liquid-glass-studio.vercel.app/ Xitoylik foydalanuvchilar uchun: https://liquid-glass.iyinchao.cn/ ## Skrinshotlar
## Asosiy xususiyatlar **✨ Apple “Liquid Glass” effektlari:** | Effekt nomi | O‘zbekcha ma’nosi | Qisqacha izoh | | ------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | **Refraction** | **Yorug‘likning sinishi** | Nur bir muhitdan boshqasiga (masalan, havodan shishaga) o‘tganda yo‘nalishini o‘zgartiradi — shu tufayli fon egilgan yoki buzilgan ko‘rinadi. | | **Dispersion** | **Ranglarga ajralish** | Yorug‘lik sinayotganda turli ranglarga (qizil, ko‘k, yashil) ajraladi — kamalak effekti hosil bo‘ladi. | | **Fresnel Reflection** | **Aks ettirish kuchi** | Aks ettirish darajasi burchakka bog‘liq: yon tomondan qaraganda ko‘proq, to‘g‘ridan qaraganda esa kamroq aks etadi. | | **Superellipse Shapes** | **Yumaloq burchakli shakllar** | Silliq o‘tishli shakllar — iOS ikonkalari kabi tabiiy va estetik ko‘rinadi. | | **Blob Effect (Shape Merging)** | **Shakllarning birlashishi** | Shaffof shakllar bir-biriga yaqinlashganda suyuq tomchidek qo‘shilib ketadi. | | **Glare** | **Yaltirash (porlash)** | Shisha yuzasida yorqin chiziq yoki nuqta paydo bo‘ladi — uni burchak, rang va o‘lcham bo‘yicha sozlash mumkin. | | **Gaussian Blur Masking** | **Gauss xiralashtirish** | Fonni silliq va yumshoq xira qiladi, shisha effekti uchun ishlatiladi. | | **Anti-aliasing** | **Qirralarni silliqlash** | Grafika qirralarining tish-tish ko‘rinmasligi uchun ularni silliqlaydi. | **⚙️ Interaktiv boshqaruvlar:** - Real vaqt rejimida barcha parametrlarni qulay UI orqali sozlash imkoniyati **🖼 Fon variantlari:** - Fon sifatida rasm yoki video ishlatish imkoniyati **🎞 Animatsiya imkoniyatlari:** - Bahorgi (spring-based) animatsiyalar — harakatlarni tabiiy ko‘rinishda boshqarish ## Texnik jihatlar - Yuqori samarali grafikani ta’minlash uchun WebGL2 / WebGPU ikki tomonlama renderlash usuli - Ko‘p bosqichli renderlash yordamida yuqori sifatli va samarali Gauss xiralashtirish amalga oshiriladi - SDF shakllar va silliq birlashtirish (smooth) funksiyasidan foydalanish - Haqiqiy shisha effektlarini yaratish uchun maxsus shaderlar - Leva UI asosidagi qulay boshqaruv interfeysi ## Boshlash ### Talablar - Node.js (so‘nggi LTS versiyasi tavsiya etiladi) - pnpm paket menejeri ### O‘rnatish ```bash # Barcha kerakli paketlarni o‘rnatish pnpm install # Ishga tushirish pnpm dev # Ishlab chiqarish (production) uchun build pnpm build ``` ## Rejalashtirilgan ishlar - [x] Yaltirash (porlash) effektini yanada ko‘proq boshqarish (porlash qanchalik tarqalgan yoki keskin bo‘lishi, rang, o‘lcham va boshqalar). - [x] O'zingiz xoxlagan fonni yuklash imkoniyati - [x] WebGPU orqali render qilish - [ ] Tahrirlash rejimi - [ ] Shisha matn - [ ] Shisha uchun tayyor andozalar - [ ] Shakl yoki obyektning o‘zi yorug‘lik chiqarishi (ya’ni, ichidan porlashi). - [ ] HDR yoritish - [x] Parametrlarni import/export qilish - [x] Render bosqichlarini ko‘rish (Render Step View) - [ ] Shakl ichida UI kontentni joylashtirish ## Tashakkurlar Quyidagi manbalar va ilhombaxsh g‘oyalar uchun minnatdorchilik bildiramiz: - [Inigo Quilez](https://iquilezles.org/) tomonidan yaratilgan [SDF funksiyalari](https://iquilezles.org/articles/distfunctions2d/) va [silliq birlashtirish](https://iquilezles.org/articles/smin/) funksiyasi - [Adrian Newell](https://unsplash.com/@anewevisual?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash) tomonidan [Unsplash’da](https://unsplash.com/photos/a-row-of-multicolored-houses-on-a-street-UtfxJZ-uy5Q?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash) olingan namuna fotosurati (Binolar) - Tom Fisk tomonidan [Pexels’da](https://www.pexels.com/video/light-city-road-traffic-4062991/) suratga olingan namuna video (Baliq / Transport harakati) - Pixabay tomonidan [Pexels’da](https://www.pexels.com/video/orange-flowers-856383/) suratga olingan namuna video (Gul) - Apple va Tim Cook tomonidan taqdim etilgan namuna fotosurati ## Litsenziya [MIT License](LICENSE) Ushbu loyiha MIT litsenziyasi ostida tarqatiladi. Bu shuni anglatadiki, siz koddan foydalanish, uni o‘zgartirish va tarqatish huquqiga egasiz — faqat mualliflikni saqlab qolgan holda. ================================================ FILE: README-zh.md ================================================ # 🔮 Liquid Glass Studio ![frontPhoto](./.github/assets/title.png) [English](README.md) | [简体中文](README-zh.md) Apple Liquid Glass UI 在 Web 平台上的高保真还原,基于 WebGL2 & WebGPU 实现。力求涵盖尽可能多的 Liquid Glass 特性,并提供细致的参数控制。 ## 在线演示 https://liquid-glass-studio.vercel.app/ 中国大陆用户请访问: https://liquid-glass.iyinchao.cn/ ## 截图预览
## 功能特性 **✨ 完整复现 Apple Liquid Glass 效果,包括**: - 折射 - 色散 - 菲涅尔反射 - 超椭圆形状(SuperEllipse) - Blob 效果(形状融合) - 高光眩光 - 高斯模糊遮罩 - 阴影 - 抗锯齿处理 **⚙️ 交互控制面板**: - 通过直观 UI 实时调整参数 **🖼 背景选项**: - 支持多种背景类型,包括图片和视频 **🎞 动画**: - 基于 Spring 动画机制,可配置参数 ## 技术 - 基于 WebGL2 / WebGPU 双后端的高性能图形渲染 - 使用多 Pass 渲染,实现高质量高性能的高斯模糊 - 使用 SDF 定义的形状和平滑合并函数 - 自定义 Shader 实现真实玻璃质感 - 使用自定义 Leva UI 控件,实现参数可视化控制 ## 快速开始 ### 环境要求 - Node.js(建议使用最新 LTS 版本) - pnpm 包管理器 ### 安装与运行 ```bash # 安装依赖 pnpm install # 启动开发服务器 pnpm dev # 构建生产版本 pnpm build ``` ## TODO - [x] 更丰富的高光控制选项(硬度 / 颜色 / 大小等) - [x] 支持自定义背景 - [x] 使用 WebGPU 渲染 - [ ] 编辑器模式 - [ ] 玻璃文字渲染 - [ ] 玻璃效果预设 - [ ] 自发光效果实现 - [ ] HDR 发光 - [x] 控制参数的导入 / 导出功能 - [x] 渲染步骤查看(展示中间处理结果) - [ ] 在玻璃形状中嵌入 UI 内容 ## 感谢 感谢以下资源和灵感来源: - [SDF 函数](https://iquilezles.org/articles/distfunctions2d/) 和 [平滑合并函数](https://iquilezles.org/articles/smin/) 来自 [Inigo Quilez](https://iquilezles.org/) - 样例图片(Buildings)由 Adrian Newell 在 [Unsplash](https://unsplash.com/photos/a-row-of-multicolored-houses-on-a-street-UtfxJZ-uy5Q) 上提供 - 样例视频(Fish / Traffic)由 Tom Fisk 在 [Pexels](https://www.pexels.com/video/light-city-road-traffic-4062991/) 上提供 - 样例视频(Flower)由 Pixabay 在 [Pexels](https://www.pexels.com/video/orange-flowers-856383/) 上提供 - 样例图片由 Apple 和 Tim Cook 提供 ## 开源协议 [MIT License](LICENSE) ================================================ FILE: README.md ================================================ # 🔮 Liquid Glass Studio ![frontPhoto](./.github/assets/title.png) [English](README.md) | [简体中文](README-zh.md) | [O‘zbekcha](README-uz.md) The Ultimate Web Recreation of Apple’s Liquid Glass UI, powered by WebGL2 & WebGPU. Includes most Liquid Glass features with fine-grained controls for detailed customization. ## Online Demo https://liquid-glass-studio.vercel.app/ For users in mainland China, please visit: https://liquid-glass.iyinchao.cn/ ## ScreenShots
## Features **✨ Apple Liquid Glass Effects:** - Refraction - Dispersion - Fresnel reflection - Superellipse shapes - Blob effect (shape merging) - Glare with customizable angle - Gaussian blur masking - Anti-aliasing **⚙️ Interactive Controls:** - Comprehensive real-time parameter adjustments via an intuitive UI **🖼 Background Options:** - Support for both images and videos as dynamic backgrounds **🎞 Animation Support:** - Spring-based shape animations with configurable behavior ## Technical Highlights - WebGL2 / WebGPU dual-backend rendering for high-performance graphics - Multipass rendering for high-quality & performant Gaussian blur - Using SDF Defined shapes and smooth merge function - Custom shader implementations for realistic glass effects - Custom Leva UI components for intuitive parameter controls ## Getting Started ### Prerequisites - Node.js (latest LTS version recommended) - pnpm package manager ### Installation ```bash # Install dependencies pnpm install # Start development server pnpm dev # Build for production pnpm build ``` ## TODO - [x] More Glare Controls (hardness / color / size etc.) - [x] Custom Background - [x] Render with WebGPU - [ ] Editor mode - [ ] Glass Text Rendering - [ ] Glass Presets - [ ] Self-illumination - [ ] HDR illumination - [x] Control parameter import / export - [x] Render Step view to show intermediate results - [ ] UI Content inside of shape ## Credits Thanks to the following resources and inspirations: - [SDF functions](https://iquilezles.org/articles/distfunctions2d/) and [smooth merge function](https://iquilezles.org/articles/smin/) by [Inigo Quilez](https://iquilezles.org/) - Sample photo (Buildings) by Adrian Newell on Unsplash - Sample video (Fish / Traffic) by Tom Fisk from [Pexels](https://www.pexels.com/video/light-city-road-traffic-4062991/) - Sample video (Flower) by Pixabay from [Pexels](https://www.pexels.com/video/orange-flowers-856383/) - Sample Photo by Apple and Tim Cook ## License [MIT License](LICENSE) ================================================ FILE: eslint.config.js ================================================ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( { ignores: ['dist'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, }, ) ================================================ FILE: index.html ================================================ Liquid Glass Studio
================================================ FILE: openspec/changes/add-webgpu-backend/design.md ================================================ ## Context The current rendering pipeline is tightly coupled to WebGL2 via `GLUtils.ts` (ShaderProgram, FrameBuffer, RenderPass, MultiPassRenderer) and GLSL shaders. Adding WebGPU requires a parallel set of utilities with an identical public interface so `App.tsx` can switch between backends at runtime. ### Key Architectural Differences: WebGL2 vs WebGPU | Aspect | WebGL2 | WebGPU | |--------|--------|--------| | Context | `canvas.getContext('webgl2')` | `navigator.gpu.requestAdapter()` → `adapter.requestDevice()` → `context.configure()` | | Shader Language | GLSL ES 3.0 | WGSL | | Pipeline | Stateful (bind program, set uniforms, draw) | Pipeline objects (immutable config), bind groups | | Uniforms | Individual `gl.uniform*()` calls | Uniform buffers (GPUBuffer) + bind groups | | Textures | `gl.bindTexture()` + `gl.activeTexture()` | GPUTexture + GPUSampler + bind group entries | | Framebuffers | FBO + texture attachment | Render pass descriptor with color/depth attachments | | Render Loop | Bind FBO → use program → set uniforms → draw → unbind | Command encoder → render pass → set pipeline/bindings → draw → submit | | Float Textures | Requires `EXT_color_buffer_float` | Native `rgba16float` format support | | Array Uniforms | `gl.uniform1fv()` | Packed into uniform buffer with proper alignment (std140/WGSL rules) | | Texture Origin | Bottom-left (UV y=0 at bottom) | Top-left (UV y=0 at top) | | `asin()` OOB | Silent clamp (implementation-defined) | Returns NaN (strict) | | `textureSample` | Allowed anywhere | Requires uniform control flow | | Canvas alpha | Default opaque compositing | Must set `alphaMode: 'opaque'` explicitly | ## Goals / Non-Goals - **Goals**: - Provide an identical visual result via WebGPU backend (STEP==9 final composition) - Create `GPUMultiPassRenderer` with same public API shape as `MultiPassRenderer` - Runtime toggle between WebGL2 and WebGPU without page reload - Graceful degradation: disable WebGPU option when unavailable - **Non-Goals**: - WebGPU-exclusive features (compute shaders, storage buffers for advanced effects) - Debug STEP visualization (0-8) in WebGPU — only STEP==9 (final output) - Performance optimization beyond feature parity (no WebGPU compute-based blur, etc.) ## Decisions ### 1. Renderer Abstraction Strategy **Decision**: Define a common `IMultiPassRenderer` TypeScript interface and implement it for both backends. `App.tsx` holds a ref to the active renderer and swaps it on toggle. **Why**: Minimal change to App.tsx. The renderer is already accessed through a single variable; adding an interface formalizes the contract. ### 2. Uniform Buffer Layout (WebGPU) **Decision**: Use a single global uniform buffer (binding 0) for all scalar/vector uniforms, and separate bind group entries for textures/samplers. Per-pass uniforms are merged and re-uploaded each frame (same pattern as WebGL). **Why**: Matches the current pattern where uniforms are set every frame. No caching needed since values change constantly. **Layout**: WGSL struct with explicit alignment matching the uniform upload order. Float arrays (blur weights) go into a separate storage buffer due to WGSL array alignment requirements. ### 3. WGSL Shader Organization **Decision**: One `.wgsl` file per pass, stored in `src/shaders-wgsl/`. Shared SDF/color utility functions are duplicated per file (WGSL has no `#include`). **Why**: WGSL lacks a preprocessor. Duplication across 4 files is manageable; shared code is ~80 lines of SDF math + color space functions. ### 4. Texture Handling **Decision**: Mirror the WebGL texture API: `loadTextureFromURL()` returns `{ texture: GPUTexture, ratio }`, `createEmptyTexture()` and `updateVideoTexture()` work similarly but use `device.queue.copyExternalImageToTexture()` for video frames. **Why**: WebGPU has native external image copy which is more efficient than texImage2D for video. **Important implementation details**: - `createImageBitmap()` must use default `colorSpaceConversion` (not `'none'`) for correct sRGB color matching with WebGL - `copyExternalImageToTexture` must use `flipY: false` because the vertex shader already flips `v_uv.y` - `bitmap.close()` invalidates `bitmap.width/height` — dimensions must be captured before closing ### 5. Canvas Swap on Toggle **Decision**: On backend toggle, use React's `key` prop to force React to unmount and remount the `` element, giving a fresh DOM element with no prior graphics context. **Why**: A canvas can only have one context type. Imperative DOM replacement (`replaceChild`) breaks React's internal tracking — the replaced element becomes orphaned (no `parentNode`). Using React's `key` mechanism is the correct declarative approach. **Implementation**: A `canvasKey` state counter increments on each backend switch. The backend-switch effect depends on `canvasKey` and fires after React has mounted the new canvas. It must then immediately apply `canvasInfo` dimensions to the fresh canvas (which starts at the default 300×150) and call `renderer.resize()`. ### 6. WGSL Coordinate System Adaptation **Decision**: Flip `v_uv.y` in the WGSL vertex shader so that `v_uv=(0,0)` maps to the top-left, matching WebGPU's texture/framebuffer origin. Use `pixel = vec2f(frag_coord.x, resolution.y - frag_coord.y)` for SDF computations that need GLSL-style bottom-up coordinates. **Why**: WebGPU textures have origin at top-left, but the SDF math and mouse coordinates are in bottom-up convention. Flipping `v_uv` in the vertex shader ensures correct inter-pass texture sampling, while `pixel` provides GLSL-compatible coordinates for SDF. **Implications**: - Procedural background patterns use `gl_uv = vec2f(v_uv.x, 1.0 - v_uv.y)` for GLSL-convention UV checks - Refraction normal offsets must negate the Y component when applied to `v_uv`: `vec2f(offset.x, -offset.y)` - External texture uploads use `flipY: false` (the vertex UV flip handles orientation) ### 7. WGSL Numerical Robustness **Decision**: Clamp `asin()` inputs to `[-1, 1]` and use `safeNormalize()` instead of `normalize()` for potentially-zero vectors. **Why**: WGSL is stricter than GLSL — `asin(>1)` returns NaN (GLSL silently clamps), and `normalize(vec2(0))` produces NaN/Inf. These NaN values propagate through `mix()`, `clamp()`, and color calculations, producing black pixels at shape edges. ## Risks / Trade-offs - **Risk**: Visual differences between WebGL and WebGPU due to floating point precision or texture filtering differences → **Mitigation**: Compare visually; both use same algorithm logic - **Risk**: WebGPU API still evolving → **Mitigation**: Target stable Chrome/Edge/Safari WebGPU APIs only - **Risk**: WGSL uniform alignment bugs → **Mitigation**: Explicit byte offset tracking in uniform buffer upload - **Risk**: Video texture performance in WebGPU → **Mitigation**: Use `copyExternalImageToTexture` which is hardware-accelerated - **Risk**: GLSL `mat3` column-major ordering confusion when porting to WGSL → **Mitigation**: Documented clearly in shader comments; verified visually against WebGL output ## Open Questions - None — all identified issues have been resolved ================================================ FILE: openspec/changes/add-webgpu-backend/proposal.md ================================================ # Change: Add WebGPU rendering backend with runtime toggle ## Why WebGPU offers better performance, modern shader capabilities, and is the future of web graphics. Adding WebGPU as an alternative backend allows users with supporting browsers to benefit while preserving the existing WebGL2 path as default. ## What Changes - **New**: Custom Leva toggle component for switching between WebGL2 and WebGPU backends - **New**: WebGPU capability detection at startup - **New**: `GPUUtils.ts` — WebGPU rendering utilities mirroring `GLUtils.ts` API (MultiPassRenderer, texture loading, video texture) - **New**: WGSL shaders porting all 4 passes (bg, vblur, hblur, main) from GLSL — main pass implements STEP==9 final composition only - **Modified**: `App.tsx` — Renderer abstraction to switch between WebGL2 and WebGPU backends - **Modified**: `parameter-controls` spec — Renderer toggle control - **Modified**: `glass-rendering` spec — WebGPU backend support ## Impact - Affected specs: `glass-rendering`, `parameter-controls` - Affected code: `src/App.tsx`, `src/Controls.tsx`, new `src/utils/GPUUtils.ts`, new `src/shaders-wgsl/`, new `src/components/LevaRendererToggle/` - **BREAKING**: None — WebGL2 remains the default ================================================ FILE: openspec/changes/add-webgpu-backend/specs/glass-rendering/spec.md ================================================ ## MODIFIED Requirements ### Requirement: Multi-Pass Rendering Pipeline The system SHALL render the glass effect using a four-pass pipeline: background pass, vertical blur pass, horizontal blur pass, and main composite pass. The pipeline SHALL support two rendering backends — WebGL2 (default) and WebGPU — both implementing the same `IMultiPassRenderer` interface. #### Scenario: Pipeline initialization (WebGL2) - **WHEN** the application mounts with the WebGL2 backend selected (default) - **THEN** the system creates a `MultiPassRenderer` with four configured passes (bgPass, vBlurPass, hBlurPass, mainPass) using WebGL2 - **AND** the system requires the `EXT_color_buffer_float` extension for RGBA16F framebuffers #### Scenario: Pipeline initialization (WebGPU) - **WHEN** the application mounts or switches to the WebGPU backend - **THEN** the system creates a `GPUMultiPassRenderer` with four configured passes using WebGPU - **AND** the system uses `rgba16float` texture format for intermediate framebuffers - **AND** WGSL shaders are used for all passes - **AND** the canvas context is configured with `alphaMode: 'opaque'` to match WebGL canvas behavior #### Scenario: Per-frame rendering - **WHEN** a new animation frame fires - **THEN** the active renderer executes all four passes in order, passing global uniforms and per-pass uniforms - **AND** intermediate pass outputs are connected as texture inputs to subsequent passes #### Scenario: Canvas resize - **WHEN** the canvas size changes - **THEN** the active renderer updates its viewport and resizes all framebuffer attachments to match the new dimensions #### Scenario: Backend switch at runtime - **WHEN** the user toggles the rendering backend - **THEN** the system disposes the current renderer, forces React to remount the canvas element via a `key` change (since WebGL and WebGPU contexts are mutually exclusive on a single canvas), and initializes the new backend - **AND** all current parameter values are preserved across the switch - **AND** the new canvas element is immediately sized to match the current `canvasInfo` dimensions (avoiding the default 300×150 canvas size) - **AND** the current background texture URL is temporarily cleared and restored via `requestAnimationFrame` so the render loop detects a change and reloads the texture for the new backend ## ADDED Requirements ### Requirement: WebGPU Backend The system SHALL provide a WebGPU rendering backend (`GPUMultiPassRenderer`) that implements the same multi-pass rendering pipeline as the WebGL2 backend, producing visually identical output for the final composition (STEP==9 equivalent). #### Scenario: WebGPU rendering - **WHEN** the WebGPU backend is active - **THEN** the system renders all four passes using WGSL shaders, GPUTextures for framebuffers, and uniform buffers for parameter data - **AND** the visual output matches the WebGL2 backend's STEP==9 final composition #### Scenario: WGSL texture sampling - **WHEN** WGSL shaders sample textures - **THEN** they use `textureSampleLevel(..., 0.0)` instead of `textureSample` to avoid WebGPU's uniform control flow restriction on `textureSample` (which cannot be called in branches that depend on non-uniform values like `frag_coord`) #### Scenario: WGSL coordinate conventions - **WHEN** WGSL shaders render to framebuffers - **THEN** the vertex shader flips `v_uv.y` (`out.uv.y = 1.0 - uv.y`) so that `v_uv=(0,0)` maps to the top-left of the texture, matching WebGPU's texture/framebuffer origin convention - **AND** fragment shaders that need GLSL-style bottom-up pixel coordinates compute `pixel = vec2f(frag_coord.x, resolution.y - frag_coord.y)` - **AND** refraction normal offsets have their Y component negated when applied to `v_uv` sampling coordinates #### Scenario: WGSL numerical safety - **WHEN** WGSL shaders compute refraction or glare effects - **THEN** `asin()` arguments are clamped to `[-1, 1]` to prevent NaN (WGSL returns NaN for out-of-range `asin`, unlike GLSL which silently clamps) - **AND** `normalize()` on potentially-zero vectors is replaced with a safe variant that returns `vec2f(0)` instead of NaN #### Scenario: WGSL color space conversion - **WHEN** WGSL shaders perform sRGB↔LCH color space conversion - **THEN** the RGB↔XYZ matrix constants are stored as true column vectors matching the GLSL `mat3` column-major layout (column 0 = first three constructor args, not the first element of each row) #### Scenario: WebGPU texture loading - **WHEN** an image background is selected in WebGPU mode - **THEN** the system loads the image via `Image` element (supporting all browser image formats including SVG), then converts to bitmap via `createImageBitmap(img)` (with default `colorSpaceConversion` for correct sRGB handling), and copies it to a `GPUTexture` using `copyExternalImageToTexture` with `flipY: false` (since `v_uv.y` is already flipped in the vertex shader) - **AND** bitmap dimensions are captured before `bitmap.close()` to compute the correct aspect ratio #### Scenario: WebGPU video texture - **WHEN** a video background is playing in WebGPU mode - **THEN** each frame is converted via `createImageBitmap(video)` for consistent sRGB color handling, then copied to the GPU texture using `copyExternalImageToTexture` with `flipY: false` - **AND** when the video dimensions change, the old texture is destroyed only after the new texture is created and written to, preventing "destroyed texture used in submit" errors - **AND** the updated `GPUTexture` reference is returned to the caller since texture recreation produces a new object ### Requirement: WebGPU Capability Detection The system SHALL detect WebGPU support at startup by checking `navigator.gpu`, requesting an adapter, and validating device capabilities. #### Scenario: WebGPU supported - **WHEN** the browser supports WebGPU and a GPU adapter is available - **THEN** the detection returns `{ supported: true }` and the WebGPU toggle option is enabled #### Scenario: WebGPU not supported - **WHEN** the browser does not support WebGPU or no adapter is available - **THEN** the detection returns `{ supported: false, reason: "..." }` and the WebGPU toggle option is disabled ### Requirement: Renderer Interface Abstraction The system SHALL define an `IMultiPassRenderer` TypeScript interface that both `MultiPassRenderer` (WebGL2) and `GPUMultiPassRenderer` (WebGPU) implement, ensuring the rendering loop in App.tsx is backend-agnostic. #### Scenario: Interface compliance - **WHEN** a renderer is created (either WebGL2 or WebGPU) - **THEN** it implements `resize()`, `setUniform()`, `setUniforms()`, `render()`, and `dispose()` methods with compatible signatures ================================================ FILE: openspec/changes/add-webgpu-backend/specs/parameter-controls/spec.md ================================================ ## ADDED Requirements ### Requirement: Renderer Backend Toggle The system SHALL display a custom Leva toggle component in the basic settings panel allowing the user to switch between WebGL2 and WebGPU rendering backends. WebGL2 SHALL be selected by default. #### Scenario: WebGPU available - **WHEN** WebGPU capability detection succeeds (async, after app mount) - **THEN** the toggle updates reactively: the "WebGPU" button transitions from disabled to enabled - **AND** clicking "WebGPU" switches the rendering backend - **AND** the Leva `useControls` dependency array includes `webgpuSupported` to ensure the toggle re-renders when detection completes #### Scenario: WebGPU unavailable - **WHEN** WebGPU capability detection fails - **THEN** the "WebGPU" button is disabled (grayed out, `cursor: not-allowed` instead of `pointer-events: none` to allow tooltip display) - **AND** hovering over the disabled button shows a tooltip with the unavailability reason in the current locale #### Scenario: Toggle i18n - **WHEN** the UI language changes - **THEN** the toggle label and tooltip text update to the current locale (en-US: "Renderer", zh-CN: "渲染引擎", uz-UZ: "Renderer") ================================================ FILE: openspec/changes/add-webgpu-backend/tasks.md ================================================ ## 1. Renderer Abstraction Interface - [x] 1.1 Define `IMultiPassRenderer` interface in `src/utils/RendererInterface.ts` with methods: `resize()`, `setUniform()`, `setUniforms()`, `render()`, `dispose()` - [x] 1.2 Define `ITextureHandle` opaque type wrapping `WebGLTexture | GPUTexture` - [x] 1.3 Define shared `RenderPassConfig` type and texture utility function signatures (`loadTextureFromURL`, `createEmptyTexture`, `updateVideoTexture`) ## 2. WebGPU Capability Detection - [x] 2.1 Create `src/utils/gpuDetect.ts` with `detectWebGPU(): Promise<{ supported: boolean; reason?: string }>` that checks `navigator.gpu`, requests adapter, and validates device capabilities - [x] 2.2 Call detection on app startup and store result in state ## 3. Renderer Toggle UI Component - [x] 3.1 Reuse existing `LevaCheckButtons` component with two buttons: "WebGL" (always enabled) and "WebGPU" (disabled with tooltip when unsupported) - [x] 3.2 Add i18n strings for the toggle in `src/utils/languages.ts` (all three locales) - [x] 3.3 Integrate toggle into `src/Controls.tsx` in the basic settings folder, before the language selector ## 4. WGSL Shaders - [x] 4.1 Create `src/shaders-wgsl/vertex.wgsl` — fullscreen quad vertex shader with UV output - [x] 4.2 Create `src/shaders-wgsl/fragment-bg.wgsl` — background rendering with procedural patterns, texture sampling, cover-fit UV, shadow computation (port from `fragment-bg.glsl`) - [x] 4.3 Create `src/shaders-wgsl/fragment-bg-vblur.wgsl` — vertical Gaussian blur (port from `fragment-bg-vblur.glsl`) - [x] 4.4 Create `src/shaders-wgsl/fragment-bg-hblur.wgsl` — horizontal Gaussian blur (port from `fragment-bg-hblur.glsl`) - [x] 4.5 Create `src/shaders-wgsl/fragment-main.wgsl` — glass effect composition: refraction, dispersion, Fresnel, glare, tint (port STEP==9 branch from `fragment-main.glsl`), SDF functions, LCH color space math ## 5. WebGPU Rendering Utilities - [x] 5.1 Create `src/utils/GPUUtils.ts` with shader module creation via combined vertex+fragment WGSL - [x] 5.2 Implement `GPUFrameBuffer` — manages `GPUTexture` (rgba16float color + depth24plus) with resize support - [x] 5.3 Implement `GPURenderPassObj` — creates render pipeline, manages bind groups for uniforms + textures/samplers, fullscreen quad vertex buffer, and render execution - [x] 5.4 Implement `GPUMultiPassRenderer` — orchestrates multi-pass rendering matching `IMultiPassRenderer` interface: constructor takes canvas + configs + device, manages global/per-pass uniforms, resolves inter-pass texture dependencies - [x] 5.5 Implement `gpuLoadTextureFromURL()` for WebGPU — creates `GPUTexture` from image URL using `createImageBitmap` + `device.queue.copyExternalImageToTexture()` - [x] 5.6 Implement `gpuCreateEmptyTexture()` for WebGPU — creates writeable `GPUTexture` for video frames - [x] 5.7 Implement `gpuUpdateVideoTexture()` for WebGPU — copies video frame to texture using `copyExternalImageToTexture()` ## 6. App.tsx Integration - [x] 6.1 Refactor `App.tsx` to use `IMultiPassRenderer` interface instead of direct `MultiPassRenderer` reference - [x] 6.2 Add renderer backend state (webgl/webgpu) and WebGPU support detection result to `stateRef` - [x] 6.3 Implement renderer swap logic: on toggle, dispose old renderer, create new renderer with appropriate backend - [x] 6.4 Adapt texture handling to work with both backends (WebGLTexture vs GPUTexture) ## 7. Validation - [ ] 7.1 Manual test: app loads with WebGL2 by default, toggle shows as "WebGL" active - [ ] 7.2 Manual test: on WebGPU-capable browser, toggling to WebGPU re-initializes rendering with identical visual output - [ ] 7.3 Manual test: on browser without WebGPU, the WebGPU button is disabled with appropriate tooltip - [ ] 7.4 Manual test: all backgrounds (procedural, image, video, custom upload) work in WebGPU mode - [ ] 7.5 Manual test: all parameter adjustments (refraction, Fresnel, glare, blur, tint, shadow, shape) work in WebGPU mode - [ ] 7.6 Manual test: toggling back to WebGL2 from WebGPU restores rendering correctly - [x] 7.7 Verify `pnpm build` succeeds without type errors ================================================ FILE: openspec/changes/archive/2026-03-23-add-initial-specs/proposal.md ================================================ # Change: Add initial specifications for existing capabilities ## Why The project has no specifications documenting its current behavior. Adding initial specs will establish a baseline for future development, making it easier to plan changes, avoid regressions, and onboard contributors. ## What Changes - New spec: `glass-rendering` — Core WebGL2 rendering pipeline and glass effects - New spec: `parameter-controls` — Real-time parameter editor UI - New spec: `background-system` — Background types and custom media support - New spec: `preset-management` — Export/import of parameter presets - New spec: `shape-system` — SDF shapes, superellipse, and blob merging ## Impact - Affected specs: All new (no existing specs) - Affected code: None — documentation only, no code changes ================================================ FILE: openspec/changes/archive/2026-03-23-add-initial-specs/specs/background-system/spec.md ================================================ ## ADDED Requirements ### Requirement: Procedural Backgrounds The system SHALL provide built-in procedural background patterns: checkerboard grid, directional bars, and half-tone split. #### Scenario: Default background - **WHEN** the application loads with bgType=0 - **THEN** a checkerboard pattern is rendered as the background ### Requirement: Image Backgrounds The system SHALL support loading bundled image backgrounds (Tahoe light, buildings, text, Tim Cook, UI mockup) as WebGL textures with cover-fit UV mapping. #### Scenario: Image background selection - **WHEN** the user selects an image background - **THEN** the image is loaded as a texture, cover-fitted to the canvas aspect ratio, and rendered behind the glass effect ### Requirement: Video Backgrounds The system SHALL support bundled video backgrounds (fish, traffic, flower) that play in a loop and update the GPU texture each frame. #### Scenario: Video background playback - **WHEN** the user selects a video background - **THEN** the video plays in a loop (muted, inline) and each frame is uploaded to the GPU texture - **AND** the video is cover-fitted to the canvas aspect ratio #### Scenario: Video pause on switch - **WHEN** the user switches away from a video background - **THEN** the previous video is paused ### Requirement: Custom Media Upload The system SHALL allow users to upload custom images or videos as backgrounds via a file picker. #### Scenario: Custom image upload - **WHEN** the user uploads an image file - **THEN** the image is loaded as a texture and used as the background #### Scenario: Custom video upload - **WHEN** the user uploads a video file - **THEN** the video plays in a loop and each frame is used as the background texture ### Requirement: Background Shadow The system SHALL render a soft shadow on the background beneath the glass shape, with configurable expand, intensity, and position offset. #### Scenario: Shadow computation - **WHEN** the background pass runs - **THEN** the shadow is computed using `exp(-1/expand * |sdf| * resolution) * 0.6 * factor` and subtracted from the background color ================================================ FILE: openspec/changes/archive/2026-03-23-add-initial-specs/specs/glass-rendering/spec.md ================================================ ## ADDED Requirements ### Requirement: Multi-Pass Rendering Pipeline The system SHALL render the glass effect using a four-pass WebGL2 pipeline: background pass, vertical blur pass, horizontal blur pass, and main composite pass. #### Scenario: Pipeline initialization - **WHEN** the application mounts and a canvas element is available - **THEN** the system creates a `MultiPassRenderer` with four configured passes (bgPass, vBlurPass, hBlurPass, mainPass) using WebGL2 - **AND** the system requires the `EXT_color_buffer_float` extension for RGBA16F framebuffers #### Scenario: Per-frame rendering - **WHEN** a new animation frame fires - **THEN** the system executes all four passes in order, passing global uniforms and per-pass uniforms - **AND** intermediate pass outputs are connected as texture inputs to subsequent passes #### Scenario: Canvas resize - **WHEN** the canvas size changes - **THEN** the system updates the GL viewport and resizes all framebuffer attachments to match the new dimensions ### Requirement: Glass Refraction Effect The system SHALL simulate light refraction through a glass-like surface using SDF-derived normals and configurable thickness/factor parameters. #### Scenario: Edge refraction - **WHEN** a pixel is inside the glass shape (SDF < 0) and within the refraction thickness boundary - **THEN** the system computes an edge factor from the incidence angle and refraction index - **AND** offsets the texture sampling position by the SDF normal scaled by the edge factor #### Scenario: Center of shape - **WHEN** a pixel is inside the glass shape but beyond the refraction thickness - **THEN** the edge factor is zero and the blurred background is sampled without offset ### Requirement: Chromatic Dispersion The system SHALL simulate chromatic dispersion by sampling R, G, B channels at slightly different UV offsets based on configurable dispersion gain. #### Scenario: Dispersion active - **WHEN** the refraction dispersion parameter is greater than zero - **THEN** the red, green, and blue channels are sampled at UV positions scaled by per-channel refractive indices (N_R=0.98, N_G=1.0, N_B=1.02) multiplied by the dispersion factor ### Requirement: Fresnel Reflection The system SHALL render a Fresnel reflection effect that increases brightness near the edges of the glass shape. #### Scenario: Fresnel at edges - **WHEN** a pixel is near the glass edge (within Fresnel range) - **THEN** the system blends toward white/tinted color based on a power-curve Fresnel factor - **AND** the factor is controlled by Fresnel range, hardness, and intensity parameters ### Requirement: Glare Effect The system SHALL render a directional glare/specular highlight on the glass surface based on the surface normal angle relative to a configurable glare direction. #### Scenario: Glare rendering - **WHEN** a pixel is inside the glass shape with a non-zero edge factor - **THEN** the system computes a glare angle factor from the normal direction and glare angle uniform - **AND** blends toward a bright LCH-lightened color, modulated by glare geometry factor, angle factor, convergence, and opposite-side factor ### Requirement: Gaussian Blur The system SHALL apply separable Gaussian blur to the background using two passes (vertical then horizontal) with a configurable radius up to 200 pixels. #### Scenario: Blur computation - **WHEN** blur radius is set to a value between 1 and 200 - **THEN** the system computes a Gaussian kernel (sigma = radius/3) and uploads weights as a uniform array - **AND** the vertical and horizontal blur shaders sample neighboring texels weighted by the kernel ### Requirement: DPR-Aware Rendering The system SHALL render at the device pixel ratio to ensure sharp output on high-DPI displays. #### Scenario: High-DPI display - **WHEN** the device pixel ratio is greater than 1 - **THEN** the canvas backing store is scaled by DPR and all SDF/shape computations account for the DPR uniform ### Requirement: Debug Step Visualization The system SHALL support a debug step mode (0-9) that visualizes intermediate rendering stages including SDF fields, normals, edge factors, blur, refraction, Fresnel, and glare individually. #### Scenario: Step selection - **WHEN** the STEP uniform is set to a value between 0 and 9 - **THEN** the main shader outputs the corresponding intermediate visualization instead of the full composite ================================================ FILE: openspec/changes/archive/2026-03-23-add-initial-specs/specs/parameter-controls/spec.md ================================================ ## ADDED Requirements ### Requirement: Leva Parameter Panel The system SHALL provide a real-time parameter editor using Leva with controls organized into logical groups (basic settings, shape settings, animation settings, debug settings). #### Scenario: Parameter adjustment - **WHEN** the user adjusts any slider, checkbox, or color picker in the Leva panel - **THEN** the corresponding shader uniform is updated on the next animation frame - **AND** the glass effect reflects the change immediately ### Requirement: Refraction Controls The system SHALL expose controls for glass refraction: thickness (1–80), refraction factor (1–4), and dispersion gain (0–50). #### Scenario: Refraction parameter range - **WHEN** the user adjusts refraction thickness - **THEN** the value is clamped between 1 and 80 with step 0.01 ### Requirement: Fresnel Controls The system SHALL expose controls for Fresnel reflection: range (0–100), hardness (0–100), and intensity (0–100). #### Scenario: Fresnel parameter adjustment - **WHEN** the user adjusts Fresnel range - **THEN** the value is clamped between 0 and 100 with step 0.01 ### Requirement: Glare Controls The system SHALL expose controls for glare: range (0–100), hardness (0–100), convergence (0–100), opposite side factor (0–100), intensity (0–120), and angle (-180 to 180 degrees). #### Scenario: Glare angle adjustment - **WHEN** the user adjusts the glare angle - **THEN** the directional highlight rotates smoothly in real time ### Requirement: Blur Controls The system SHALL expose blur radius (1–200, integer step) and blur edge toggle. #### Scenario: Blur edge toggle - **WHEN** blur edge is enabled - **THEN** the entire glass interior uses the blurred background - **WHEN** blur edge is disabled - **THEN** the blur-to-clear mix is proportional to the edge height within the glass ### Requirement: Tint Control The system SHALL expose an RGBA color picker for glass tint that blends over the refracted background. #### Scenario: Tint application - **WHEN** the user sets a tint with alpha > 0 - **THEN** the glass surface shows a colored overlay at the specified opacity ### Requirement: Shadow Controls The system SHALL expose shadow expand (2–100), shadow intensity (0–100), and shadow position (2D vector with ±20 range). #### Scenario: Shadow rendering - **WHEN** shadow intensity is greater than 0 - **THEN** a soft shadow appears beneath the glass shape, offset by the shadow position vector ### Requirement: Internationalization The system SHALL support language switching between English (en-US), Simplified Chinese (zh-CN), and Uzbek (uz-UZ) with auto-detection of browser language. #### Scenario: Language auto-detection - **WHEN** the application loads - **THEN** the UI language is set to Chinese if the browser language starts with "zh", Uzbek if it starts with "uz", and English otherwise #### Scenario: Manual language switch - **WHEN** the user selects a different language - **THEN** all control labels and UI strings update immediately ================================================ FILE: openspec/changes/archive/2026-03-23-add-initial-specs/specs/preset-management/spec.md ================================================ ## ADDED Requirements ### Requirement: Preset Export The system SHALL allow users to export all current control parameter values as a JSON file with version, timestamp, and control data. #### Scenario: Export to file - **WHEN** the user clicks the export button - **THEN** a JSON file named `liquid-glass-.json` is downloaded containing version "1.0.0", ISO timestamp, and a deep clone of all control values ### Requirement: Preset Import The system SHALL allow users to import a previously exported preset JSON file, restoring all control parameter values. #### Scenario: Successful import - **WHEN** the user selects a valid preset JSON file - **THEN** all control values are restored from the file via the Leva controls API - **AND** a success message is displayed #### Scenario: Invalid file - **WHEN** the user selects a file that is not valid JSON or lacks required fields (version, controls) - **THEN** an error message is displayed with the failure reason ================================================ FILE: openspec/changes/archive/2026-03-23-add-initial-specs/specs/shape-system/spec.md ================================================ ## ADDED Requirements ### Requirement: Superellipse Shape The system SHALL render the primary glass shape as a rounded rectangle using superellipse corner SDF with configurable width (20–800), height (20–800), radius (1–100%), and roundness (2–7). #### Scenario: Shape parameter adjustment - **WHEN** the user adjusts shape width, height, radius, or roundness - **THEN** the glass shape updates immediately to reflect the new parameters - **AND** the roundness parameter controls the superellipse exponent (2=diamond-like, 4-5=squircle, 7=near-rectangle) ### Requirement: Secondary Circle Shape The system SHALL optionally render a secondary circle shape at the center of the canvas, toggleable via the "Show 2nd Shape" control. #### Scenario: Toggle secondary shape - **WHEN** the user enables "Show 2nd Shape" - **THEN** a circle with radius 100px (DPR-adjusted) appears at the canvas center - **AND** it participates in shape merging with the primary shape ### Requirement: Shape Blob Merging The system SHALL blend the primary and secondary shapes together using a smooth minimum (smin) function with configurable merge rate (0–0.3). #### Scenario: Merge effect - **WHEN** merge rate is greater than 0 and the secondary shape is enabled - **THEN** the two shapes blend smoothly at their boundaries, creating an organic blob-like transition #### Scenario: No merge - **WHEN** merge rate is 0 - **THEN** the shapes remain separate with distinct boundaries ### Requirement: Mouse-Following Shape Position The system SHALL position the primary shape at the pointer location on the canvas, using spring-based animation for smooth following. #### Scenario: Mouse movement - **WHEN** the user moves the pointer over the canvas - **THEN** the primary shape follows the pointer with spring-based easing - **AND** the spring velocity is tracked for animation morph effects ### Requirement: Spring-Based Size Animation The system SHALL deform the shape size based on pointer movement speed, controlled by the animation morph factor (0–50). #### Scenario: Fast movement - **WHEN** the user moves the pointer quickly and the morph factor is greater than 0 - **THEN** the shape stretches in the direction of movement proportional to the speed and morph factor ### Requirement: Resizable Canvas Window The system SHALL display the WebGL canvas in a resizable window that is centered on the viewport and adjustable via drag handles. #### Scenario: Canvas resize - **WHEN** the user drags the resize handle - **THEN** the canvas dimensions update and the WebGL viewport/framebuffers resize accordingly - **AND** the window re-centers after resize ================================================ FILE: openspec/changes/archive/2026-03-23-add-initial-specs/tasks.md ================================================ ## 1. Documentation - [x] 1.1 Update `openspec/project.md` with project context, tech stack, conventions, and domain knowledge - [x] 1.2 Create `glass-rendering` spec covering the multi-pass pipeline and glass effects - [x] 1.3 Create `parameter-controls` spec covering the Leva-based editor UI - [x] 1.4 Create `background-system` spec covering background types and custom media - [x] 1.5 Create `preset-management` spec covering export/import functionality - [x] 1.6 Create `shape-system` spec covering SDF shapes and blob merging - [x] 1.7 Validate all specs with `openspec validate add-initial-specs --strict` ================================================ FILE: openspec/project.md ================================================ # Project Context ## Purpose Liquid Glass Studio is a web-based interactive tool that recreates Apple's Liquid Glass UI effects using WebGL2 / WebGPU and custom shaders (GLSL / WGSL). Users can manipulate glass effect parameters in real-time through a visual editor, preview effects on various backgrounds, and export/import parameter presets. ## Tech Stack - **Language**: TypeScript (strict mode, ES2020 target) - **Framework**: React 19 + Vite 6 - **Rendering**: WebGL2 (GLSL ES 3.0) / WebGPU (WGSL) dual-backend, runtime switchable - **UI Controls**: [Leva](https://github.com/pmndrs/leva) for parameter panels, with custom components - **Animation**: @react-spring/web for spring-based animations - **Styling**: SCSS (modules + global), clsx for classname composition - **UI Components**: MUI Material Icons, re-resizable - **Package Manager**: pnpm - **Path Aliases**: `@` → `src/` (via tsconfig paths + vite-tsconfig-paths) ## Project Conventions ### Code Style - Strict TypeScript with `erasableSyntaxOnly` - ESLint with React Hooks and React Refresh plugins - Prettier with GLSL plugin for shader formatting - Component files: PascalCase (e.g., `ResizableWindow.tsx`) - Utility files: camelCase (e.g., `presetUtils.ts`) - Shader files: kebab-case with descriptive names (e.g., `fragment-main.glsl`) - SCSS modules for component-scoped styles (`.module.scss`) - Chinese comments are common in shader and GL utility code ### Architecture Patterns - **Single-page app** with a monolithic `App.tsx` that owns the WebGL rendering loop - **Multi-pass rendering pipeline**: Background → Vertical Blur → Horizontal Blur → Main composite - **Ref-based state** (`stateRef`) for render-loop data that doesn't trigger React re-renders - **Leva controls** as the primary UI, with custom Leva components (`LevaContainer`, `LevaCheckButtons`, `LevaVectorNew`, `LevaButton`) - **Shader uniforms** driven directly by Leva control values each frame - All shader programs share a common vertex shader (fullscreen quad) - SDF-based shape rendering with smooth merge (smin) for blob effects ### Rendering Pipeline 1. **bgPass**: Renders background (procedural patterns or texture/video) + shadow 2. **vBlurPass**: Vertical Gaussian blur of bgPass output 3. **hBlurPass**: Horizontal Gaussian blur of vBlurPass output 4. **mainPass**: Composites glass effect (refraction, dispersion, Fresnel, glare) on top of blurred/unblurred background; uses `STEP` uniform for debug visualization ### Testing Strategy - No automated tests currently exist - Manual testing via the interactive UI - Debug `STEP` control (0-9) for visualizing intermediate rendering stages ### Git Workflow - Main branch development - Deployed to Vercel (liquid-glass-studio.vercel.app) ## Domain Context - **SDF (Signed Distance Function)**: Mathematical function returning distance to shape boundary; negative inside, positive outside - **Superellipse**: Generalization of ellipse with adjustable roundness parameter - **Smooth min (smin)**: Blends two SDF shapes together smoothly (blob/merge effect) - **Fresnel reflection**: Light reflection increases at glancing angles - **Dispersion**: Chromatic separation of light into RGB channels - **Glare**: Directional light effect controlled by angle and convergence - **Multi-pass Gaussian blur**: Separable blur done in two passes (H + V) for performance - **LCH color space**: Used for perceptually uniform color blending in glare/fresnel tinting ## Important Constraints - WebGL2 is the default backend; WebGPU is available on supported browsers (Chrome 113+, Edge 113+, Safari 18+) - WebGL2 backend requires `EXT_color_buffer_float` extension for HDR framebuffers (RGBA16F) - WebGPU backend uses native `rgba16float` format for intermediate framebuffers - WebGL and WebGPU contexts are mutually exclusive on a canvas; switching backends remounts the canvas element via React `key` - Shader uniform arrays limited to MAX_BLUR_RADIUS=200 - All rendering is in a single `requestAnimationFrame` loop - Canvas size affects GPU workload; DPR-aware rendering ## External Dependencies - Vercel for deployment - No backend or API dependencies - All assets (images, videos) bundled in `src/assets/` ## Key Files - `src/App.tsx` — Main application, rendering loop, WebGL setup - `src/Controls.tsx` — All Leva parameter definitions - `src/utils/GLUtils.ts` — WebGL abstraction (ShaderProgram, FrameBuffer, RenderPass, MultiPassRenderer) - `src/shaders/fragment-main.glsl` — Core glass effect shader - `src/shaders/fragment-bg.glsl` — Background + shadow shader - `src/shaders/fragment-bg-vblur.glsl` / `fragment-bg-hblur.glsl` — Separable Gaussian blur - `src/shaders/vertex.glsl` — Shared fullscreen quad vertex shader - `src/utils/presetUtils.ts` — Preset export/import logic - `src/utils/languages.ts` — i18n strings (zh-CN, en-US, uz-UZ) ================================================ FILE: openspec/specs/background-system/spec.md ================================================ # background-system Specification ## Purpose TBD - created by archiving change add-initial-specs. Update Purpose after archive. ## Requirements ### Requirement: Procedural Backgrounds The system SHALL provide built-in procedural background patterns: checkerboard grid, directional bars, and half-tone split. #### Scenario: Default background - **WHEN** the application loads with bgType=0 - **THEN** a checkerboard pattern is rendered as the background ### Requirement: Image Backgrounds The system SHALL support loading bundled image backgrounds (Tahoe light, buildings, text, Tim Cook, UI mockup) as WebGL textures with cover-fit UV mapping. #### Scenario: Image background selection - **WHEN** the user selects an image background - **THEN** the image is loaded as a texture, cover-fitted to the canvas aspect ratio, and rendered behind the glass effect ### Requirement: Video Backgrounds The system SHALL support bundled video backgrounds (fish, traffic, flower) that play in a loop and update the GPU texture each frame. #### Scenario: Video background playback - **WHEN** the user selects a video background - **THEN** the video plays in a loop (muted, inline) and each frame is uploaded to the GPU texture - **AND** the video is cover-fitted to the canvas aspect ratio #### Scenario: Video pause on switch - **WHEN** the user switches away from a video background - **THEN** the previous video is paused ### Requirement: Custom Media Upload The system SHALL allow users to upload custom images or videos as backgrounds via a file picker. #### Scenario: Custom image upload - **WHEN** the user uploads an image file - **THEN** the image is loaded as a texture and used as the background #### Scenario: Custom video upload - **WHEN** the user uploads a video file - **THEN** the video plays in a loop and each frame is used as the background texture ### Requirement: Background Shadow The system SHALL render a soft shadow on the background beneath the glass shape, with configurable expand, intensity, and position offset. #### Scenario: Shadow computation - **WHEN** the background pass runs - **THEN** the shadow is computed using `exp(-1/expand * |sdf| * resolution) * 0.6 * factor` and subtracted from the background color ================================================ FILE: openspec/specs/glass-rendering/spec.md ================================================ # glass-rendering Specification ## Purpose TBD - created by archiving change add-initial-specs. Update Purpose after archive. ## Requirements ### Requirement: Multi-Pass Rendering Pipeline The system SHALL render the glass effect using a four-pass WebGL2 pipeline: background pass, vertical blur pass, horizontal blur pass, and main composite pass. #### Scenario: Pipeline initialization - **WHEN** the application mounts and a canvas element is available - **THEN** the system creates a `MultiPassRenderer` with four configured passes (bgPass, vBlurPass, hBlurPass, mainPass) using WebGL2 - **AND** the system requires the `EXT_color_buffer_float` extension for RGBA16F framebuffers #### Scenario: Per-frame rendering - **WHEN** a new animation frame fires - **THEN** the system executes all four passes in order, passing global uniforms and per-pass uniforms - **AND** intermediate pass outputs are connected as texture inputs to subsequent passes #### Scenario: Canvas resize - **WHEN** the canvas size changes - **THEN** the system updates the GL viewport and resizes all framebuffer attachments to match the new dimensions ### Requirement: Glass Refraction Effect The system SHALL simulate light refraction through a glass-like surface using SDF-derived normals and configurable thickness/factor parameters. #### Scenario: Edge refraction - **WHEN** a pixel is inside the glass shape (SDF < 0) and within the refraction thickness boundary - **THEN** the system computes an edge factor from the incidence angle and refraction index - **AND** offsets the texture sampling position by the SDF normal scaled by the edge factor #### Scenario: Center of shape - **WHEN** a pixel is inside the glass shape but beyond the refraction thickness - **THEN** the edge factor is zero and the blurred background is sampled without offset ### Requirement: Chromatic Dispersion The system SHALL simulate chromatic dispersion by sampling R, G, B channels at slightly different UV offsets based on configurable dispersion gain. #### Scenario: Dispersion active - **WHEN** the refraction dispersion parameter is greater than zero - **THEN** the red, green, and blue channels are sampled at UV positions scaled by per-channel refractive indices (N_R=0.98, N_G=1.0, N_B=1.02) multiplied by the dispersion factor ### Requirement: Fresnel Reflection The system SHALL render a Fresnel reflection effect that increases brightness near the edges of the glass shape. #### Scenario: Fresnel at edges - **WHEN** a pixel is near the glass edge (within Fresnel range) - **THEN** the system blends toward white/tinted color based on a power-curve Fresnel factor - **AND** the factor is controlled by Fresnel range, hardness, and intensity parameters ### Requirement: Glare Effect The system SHALL render a directional glare/specular highlight on the glass surface based on the surface normal angle relative to a configurable glare direction. #### Scenario: Glare rendering - **WHEN** a pixel is inside the glass shape with a non-zero edge factor - **THEN** the system computes a glare angle factor from the normal direction and glare angle uniform - **AND** blends toward a bright LCH-lightened color, modulated by glare geometry factor, angle factor, convergence, and opposite-side factor ### Requirement: Gaussian Blur The system SHALL apply separable Gaussian blur to the background using two passes (vertical then horizontal) with a configurable radius up to 200 pixels. #### Scenario: Blur computation - **WHEN** blur radius is set to a value between 1 and 200 - **THEN** the system computes a Gaussian kernel (sigma = radius/3) and uploads weights as a uniform array - **AND** the vertical and horizontal blur shaders sample neighboring texels weighted by the kernel ### Requirement: DPR-Aware Rendering The system SHALL render at the device pixel ratio to ensure sharp output on high-DPI displays. #### Scenario: High-DPI display - **WHEN** the device pixel ratio is greater than 1 - **THEN** the canvas backing store is scaled by DPR and all SDF/shape computations account for the DPR uniform ### Requirement: Debug Step Visualization The system SHALL support a debug step mode (0-9) that visualizes intermediate rendering stages including SDF fields, normals, edge factors, blur, refraction, Fresnel, and glare individually. #### Scenario: Step selection - **WHEN** the STEP uniform is set to a value between 0 and 9 - **THEN** the main shader outputs the corresponding intermediate visualization instead of the full composite ================================================ FILE: openspec/specs/parameter-controls/spec.md ================================================ # parameter-controls Specification ## Purpose TBD - created by archiving change add-initial-specs. Update Purpose after archive. ## Requirements ### Requirement: Leva Parameter Panel The system SHALL provide a real-time parameter editor using Leva with controls organized into logical groups (basic settings, shape settings, animation settings, debug settings). #### Scenario: Parameter adjustment - **WHEN** the user adjusts any slider, checkbox, or color picker in the Leva panel - **THEN** the corresponding shader uniform is updated on the next animation frame - **AND** the glass effect reflects the change immediately ### Requirement: Refraction Controls The system SHALL expose controls for glass refraction: thickness (1–80), refraction factor (1–4), and dispersion gain (0–50). #### Scenario: Refraction parameter range - **WHEN** the user adjusts refraction thickness - **THEN** the value is clamped between 1 and 80 with step 0.01 ### Requirement: Fresnel Controls The system SHALL expose controls for Fresnel reflection: range (0–100), hardness (0–100), and intensity (0–100). #### Scenario: Fresnel parameter adjustment - **WHEN** the user adjusts Fresnel range - **THEN** the value is clamped between 0 and 100 with step 0.01 ### Requirement: Glare Controls The system SHALL expose controls for glare: range (0–100), hardness (0–100), convergence (0–100), opposite side factor (0–100), intensity (0–120), and angle (-180 to 180 degrees). #### Scenario: Glare angle adjustment - **WHEN** the user adjusts the glare angle - **THEN** the directional highlight rotates smoothly in real time ### Requirement: Blur Controls The system SHALL expose blur radius (1–200, integer step) and blur edge toggle. #### Scenario: Blur edge toggle - **WHEN** blur edge is enabled - **THEN** the entire glass interior uses the blurred background - **WHEN** blur edge is disabled - **THEN** the blur-to-clear mix is proportional to the edge height within the glass ### Requirement: Tint Control The system SHALL expose an RGBA color picker for glass tint that blends over the refracted background. #### Scenario: Tint application - **WHEN** the user sets a tint with alpha > 0 - **THEN** the glass surface shows a colored overlay at the specified opacity ### Requirement: Shadow Controls The system SHALL expose shadow expand (2–100), shadow intensity (0–100), and shadow position (2D vector with ±20 range). #### Scenario: Shadow rendering - **WHEN** shadow intensity is greater than 0 - **THEN** a soft shadow appears beneath the glass shape, offset by the shadow position vector ### Requirement: Internationalization The system SHALL support language switching between English (en-US), Simplified Chinese (zh-CN), and Uzbek (uz-UZ) with auto-detection of browser language. #### Scenario: Language auto-detection - **WHEN** the application loads - **THEN** the UI language is set to Chinese if the browser language starts with "zh", Uzbek if it starts with "uz", and English otherwise #### Scenario: Manual language switch - **WHEN** the user selects a different language - **THEN** all control labels and UI strings update immediately ================================================ FILE: openspec/specs/preset-management/spec.md ================================================ # preset-management Specification ## Purpose TBD - created by archiving change add-initial-specs. Update Purpose after archive. ## Requirements ### Requirement: Preset Export The system SHALL allow users to export all current control parameter values as a JSON file with version, timestamp, and control data. #### Scenario: Export to file - **WHEN** the user clicks the export button - **THEN** a JSON file named `liquid-glass-.json` is downloaded containing version "1.0.0", ISO timestamp, and a deep clone of all control values ### Requirement: Preset Import The system SHALL allow users to import a previously exported preset JSON file, restoring all control parameter values. #### Scenario: Successful import - **WHEN** the user selects a valid preset JSON file - **THEN** all control values are restored from the file via the Leva controls API - **AND** a success message is displayed #### Scenario: Invalid file - **WHEN** the user selects a file that is not valid JSON or lacks required fields (version, controls) - **THEN** an error message is displayed with the failure reason ================================================ FILE: openspec/specs/shape-system/spec.md ================================================ # shape-system Specification ## Purpose TBD - created by archiving change add-initial-specs. Update Purpose after archive. ## Requirements ### Requirement: Superellipse Shape The system SHALL render the primary glass shape as a rounded rectangle using superellipse corner SDF with configurable width (20–800), height (20–800), radius (1–100%), and roundness (2–7). #### Scenario: Shape parameter adjustment - **WHEN** the user adjusts shape width, height, radius, or roundness - **THEN** the glass shape updates immediately to reflect the new parameters - **AND** the roundness parameter controls the superellipse exponent (2=diamond-like, 4-5=squircle, 7=near-rectangle) ### Requirement: Secondary Circle Shape The system SHALL optionally render a secondary circle shape at the center of the canvas, toggleable via the "Show 2nd Shape" control. #### Scenario: Toggle secondary shape - **WHEN** the user enables "Show 2nd Shape" - **THEN** a circle with radius 100px (DPR-adjusted) appears at the canvas center - **AND** it participates in shape merging with the primary shape ### Requirement: Shape Blob Merging The system SHALL blend the primary and secondary shapes together using a smooth minimum (smin) function with configurable merge rate (0–0.3). #### Scenario: Merge effect - **WHEN** merge rate is greater than 0 and the secondary shape is enabled - **THEN** the two shapes blend smoothly at their boundaries, creating an organic blob-like transition #### Scenario: No merge - **WHEN** merge rate is 0 - **THEN** the shapes remain separate with distinct boundaries ### Requirement: Mouse-Following Shape Position The system SHALL position the primary shape at the pointer location on the canvas, using spring-based animation for smooth following. #### Scenario: Mouse movement - **WHEN** the user moves the pointer over the canvas - **THEN** the primary shape follows the pointer with spring-based easing - **AND** the spring velocity is tracked for animation morph effects ### Requirement: Spring-Based Size Animation The system SHALL deform the shape size based on pointer movement speed, controlled by the animation morph factor (0–50). #### Scenario: Fast movement - **WHEN** the user moves the pointer quickly and the morph factor is greater than 0 - **THEN** the shape stretches in the direction of movement proportional to the speed and morph factor ### Requirement: Resizable Canvas Window The system SHALL display the WebGL canvas in a resizable window that is centered on the viewport and adjustable via drag handles. #### Scenario: Canvas resize - **WHEN** the user drags the resize handle - **THEN** the canvas dimensions update and the WebGL viewport/framebuffers resize accordingly - **AND** the window re-centers after resize ================================================ FILE: package.json ================================================ { "name": "liquid-glass", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" }, "dependencies": { "@emotion/styled": "^11.14.0", "@mui/icons-material": "^7.1.1", "@mui/material": "^7.1.1", "@react-spring/web": "^10.0.1", "clsx": "^2.1.1", "leva": "^0.10.0", "modern-normalize": "^3.0.1", "re-resizable": "^6.11.2", "react": "^19.1.0", "react-dom": "^19.1.0" }, "devDependencies": { "@eslint/js": "^9.25.0", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react-swc": "^3.9.0", "@webgpu/types": "^0.1.69", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", "prettier": "^3.5.3", "prettier-plugin-glsl": "^0.2.1", "sass": "^1.89.2", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4" } } ================================================ FILE: src/App.module.scss ================================================ .header { position: fixed; left: 50%; top: 12px; padding: 8px 12px; gap: 8px; transform: translateX(-50%); color: white; font-size: 12px; display: flex; flex-direction: column; background: var(--bg-color-fore); box-shadow: 0 2px 7px rgba(0, 0, 0, 0.5); border-radius: 8px; align-items: center; font-family: sans-serif; .logoWrapper { display: flex; flex-direction: row; align-items: center; gap: 12px; } .title { font-size: 16px; font-weight: bold; } .subtitle { color: rgba(white, 0.75); font-style: italic; } .content { display: flex; align-items: center; gap: 8px; .button, .button > svg { width: 16px; height: 16px; } .button { color: white; cursor: pointer; &:hover { color: rgb(150, 186, 222); } } } } .canvasContainer { width: 100%; height: 100%; overflow: hidden; } .canvas { transform: scale(calc(1 / var(--dpr))); transform-origin: top left; touch-action: none; } .bgSelect { display: flex; flex-wrap: wrap; gap: 6px; margin-left: -44px; z-index: 2; position: relative; .bgSelectItem { width: 60px; height: 60px; flex-shrink: 0; border: 1px solid var(--leva-colors-elevation3); border-radius: var(--leva-radii-sm); background-size: cover; overflow: hidden; position: relative; &Active { outline: 2px solid var(--leva-colors-accent2); } &Overlay { position: absolute; left: 0; right: 0; top: 0; bottom: 0; .bgSelectItemVideoIcon { position: absolute; bottom: 0; left: 0; width: 16px; height: 16px; color: rgba(white, 0.8); filter: drop-shadow(0 1px 1.5px rgba(black, 0.8)); transition: opacity 300ms; } .bgSelectItemCustomIcon { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.8); padding: 5px; border-radius: 100%; display: flex; align-items: center; justify-content: center; color: rgba(white, 0.9); // filter: drop-shadow(0 1px 2px rgba(black, 0.5)); background-color: rgba(black, 0.3); } } &TypeCustom { --alpha-pattern-size: 20px; --alpha-pattern-color-a: #5b5f6b; --alpha-pattern-color-b: var(--leva-colors-elevation1); &::before { content: ''; position: absolute; inset: 0; background-color: var(--alpha-pattern-color-b); background-image: repeating-linear-gradient( 45deg, var(--alpha-pattern-color-a) 25%, transparent 25%, transparent 75%, var(--alpha-pattern-color-a) 75%, var(--alpha-pattern-color-a) ), repeating-linear-gradient( 45deg, var(--alpha-pattern-color-a) 25%, var(--alpha-pattern-color-b) 25%, var(--alpha-pattern-color-b) 75%, var(--alpha-pattern-color-a) 75%, var(--alpha-pattern-color-a) ); background-position: 0 0, calc(var(--alpha-pattern-size) / 2) calc(var(--alpha-pattern-size) / 2); background-size: var(--alpha-pattern-size) var(--alpha-pattern-size); } } } } .bgSelectItemVideo, .bgSelectItemImg { left: 0; top: 0; position: absolute; width: 100%; height: 100%; object-fit: cover; } :global(#root > div[class^='leva-']) { background-color: rgba(0, 11, 36, 0.4); & > div:last-child { background-color: rgba(0, 9, 30, 0.6); } } :global(div[data-leva-folder='1']) { & > div:first-child { & > svg + div { display: none; } &::after { content: var(--i18n-name); display: inline; } } & > div:last-child { background-color: transparent; } } ================================================ FILE: src/App.tsx ================================================ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, } from 'react'; import styles from './App.module.scss'; import { createEmptyTexture, loadTextureFromURL, MultiPassRenderer, updateVideoTexture, } from './utils/GLUtils'; import type { IMultiPassRenderer, ITextureHandle } from './utils/RendererInterface'; import { GPUMultiPassRenderer, gpuLoadTextureFromURL, gpuCreateEmptyTexture, gpuUpdateVideoTexture, } from './utils/GPUUtils'; import { detectWebGPU, type WebGPUDetectResult } from './utils/gpuDetect'; import { ResizableWindow } from './components/ResizableWindow'; import type { ResizeWindowCtrlRefType } from './components/ResizableWindow/ResizableWindow'; import VertexShader from './shaders/vertex.glsl?raw'; import FragmentBgShader from './shaders/fragment-bg.glsl?raw'; import FragmentBgVblurShader from './shaders/fragment-bg-vblur.glsl?raw'; import FragmentBgHblurShader from './shaders/fragment-bg-hblur.glsl?raw'; import FragmentMainShader from './shaders/fragment-main.glsl?raw'; import WgslVertex from './shaders-wgsl/vertex.wgsl?raw'; import WgslFragBg from './shaders-wgsl/fragment-bg.wgsl?raw'; import WgslFragVblur from './shaders-wgsl/fragment-bg-vblur.wgsl?raw'; import WgslFragHblur from './shaders-wgsl/fragment-bg-hblur.wgsl?raw'; import WgslFragMain from './shaders-wgsl/fragment-main.wgsl?raw'; import { Controller } from '@react-spring/web'; // import { useResizeObserver } from './utils/useResizeOberver'; import clsx from 'clsx'; import { capitalize, computeGaussianKernelByRadius } from './utils'; import bgGrid from '@/assets/bg-grid.png'; import bgBars from '@/assets/bg-bars.png'; import bgHalf from '@/assets/bg-half.png'; import bgTimcook from '@/assets/bg-timcook.png'; import bgUI from '@/assets/bg-ui.svg'; import bgTahoeLightImg from '@/assets/bg-tahoe-light.webp'; import bgText from '@/assets/bg-text.jpg'; import bgBuildings from '@/assets/bg-buildings.png'; import bgVideoFish from '@/assets/bg-video-fish.mp4'; import bgVideo2 from '@/assets/bg-video-2.mp4'; import bgVideo3 from '@/assets/bg-video-3.mp4'; import XIcon from '@mui/icons-material/X'; import GitHubIcon from '@mui/icons-material/GitHub'; import PlayCircleOutlinedIcon from '@mui/icons-material/PlayCircleOutlined'; import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined'; import { useLevaControls } from './Controls'; import { PresetControls } from './components/PresetControls/PresetControls'; function App() { const canvasRef = useRef(null); const [canvasInfo, setCanvasInfo] = useState<{ width: number; height: number; dpr: number }>({ width: Math.max(Math.min(window.innerWidth, window.innerHeight) - 150, 600), height: Math.max(Math.min(window.innerWidth, window.innerHeight) - 150, 600), dpr: 1, }); // WebGPU detection const [webgpuDetect, setWebgpuDetect] = useState(null); const [rendererBackend, setRendererBackend] = useState<'webgl' | 'webgpu'>('webgl'); // Incrementing key forces React to remount the , giving us a fresh element // with no prior context (needed because WebGL/WebGPU contexts are mutually exclusive) const [canvasKey, setCanvasKey] = useState(0); useEffect(() => { detectWebGPU().then(setWebgpuDetect); }, []); const { controls, lang, langName, levaGlobal, controlsAPI } = useLevaControls({ rendererOptions: { webgpuSupported: webgpuDetect?.supported ?? false, webgpuUnavailableReason: webgpuDetect?.reason, onRendererChange: (backend) => { setRendererBackend(backend); setCanvasKey((k) => k + 1); }, }, containerRender: { /* eslint-disable react-hooks/rules-of-hooks */ bgType: ({ value, setValue }) => { const [customFileType, setCustomFileType] = useState(null); const [customFile, setCustomFile] = useState(null); const [customFileUrl, setCustomFileUrl] = useState(null); const fileInputRef = useRef(null); return (
{[ { v: 11, media: '', loadTexture: true, type: 'custom' as const }, { v: 0, media: bgGrid, loadTexture: false }, { v: 1, media: bgBars, loadTexture: false }, { v: 2, media: bgHalf, loadTexture: false }, { v: 3, media: bgTahoeLightImg, loadTexture: true }, { v: 4, media: bgBuildings, loadTexture: true }, { v: 5, media: bgText, loadTexture: true }, { v: 6, media: bgTimcook, loadTexture: true }, { v: 7, media: bgUI, loadTexture: true }, { v: 8, media: bgVideoFish, loadTexture: true, type: 'video' as const }, { v: 9, media: bgVideo2, loadTexture: true, type: 'video' as const }, { v: 10, media: bgVideo3, loadTexture: true, type: 'video' as const }, ].map(({ v, media, loadTexture, type }) => { const mediaType = type === 'custom' ? customFileType : (type ?? 'image'); const mediaUrl = type === 'custom' ? customFileUrl : media; return (
{ if (type === 'custom') { if (!mediaUrl) { fileInputRef.current?.click(); } else if (value === v) { fileInputRef.current?.click(); } } setValue(v); if (loadTexture && mediaUrl) { stateRef.current.bgTextureUrl = mediaUrl; if (mediaType === 'video') { stateRef.current.bgTextureType = 'video'; } else { stateRef.current.bgTextureType = 'image'; } } else { stateRef.current.bgTextureUrl = null; stateRef.current.bgTextureReady = false; } }} > {mediaUrl && (mediaType === 'video' ? ( ) : mediaType === 'image' ? ( ) : null)} {type === 'custom' ? ( <> { if (!e.target.files?.[0]) { return; } setCustomFile(e.target.files[0]); if (customFileUrl) { URL.revokeObjectURL(customFileUrl); } const newUrl = URL.createObjectURL(e.target.files[0]); setCustomFileUrl(newUrl); const fileType = e.target.files[0].type.startsWith('image/') ? 'image' : 'video'; setCustomFileType(fileType); setValue(v); stateRef.current.bgTextureUrl = newUrl; if (fileType === 'video') { stateRef.current.bgTextureType = 'video'; } else { stateRef.current.bgTextureType = 'image'; } }} > ) : null}
{mediaType === 'video' && ( )} {type === 'custom' && (
)}
); })}
); }, /* eslint-enable react-hooks/rules-of-hooks */ }, }); const stateRef = useRef<{ canvasWindowCtrlRef: ResizeWindowCtrlRefType | null; renderRaf: number | null; canvasInfo: typeof canvasInfo; glStates: { gl: WebGL2RenderingContext; programs: Record; vao: WebGLVertexArrayObject; } | null; canvasPos: { x: number; y: number }; canvasPointerPos: { x: number; y: number }; controls: typeof controls; blurWeights: number[]; lastMouseSpringValue: { x: number; y: number }; lastMouseSpringTime: null | number; mouseSpring: Controller<{ x: number; y: number }>; mouseSpringSpeed: { x: number; y: number }; bgTextureUrl: string | null; bgTexture: ITextureHandle | null; bgTextureRatio: number; bgTextureType: 'image' | 'video' | null; bgTextureReady: boolean; bgVideoEls: Map; langName: typeof langName; rendererBackend: 'webgl' | 'webgpu'; activeRenderer: IMultiPassRenderer | null; gpuDevice: GPUDevice | null; }>({ canvasWindowCtrlRef: null, renderRaf: null, glStates: null, canvasInfo, canvasPos: { x: 0, y: 0, }, canvasPointerPos: { x: 0, y: 0, }, controls, blurWeights: [], lastMouseSpringValue: { x: 0, y: 0, }, lastMouseSpringTime: null, mouseSpring: new Controller({ x: 0, y: 0, onChange: (c) => { if (!stateRef.current.lastMouseSpringTime) { stateRef.current.lastMouseSpringTime = Date.now(); stateRef.current.lastMouseSpringValue = c.value; return; } const now = Date.now(); const lastValue = stateRef.current.lastMouseSpringValue; const dt = now - stateRef.current.lastMouseSpringTime; const dx = { x: c.value.x - lastValue.x, y: c.value.y - lastValue.y, }; const speed = { x: dx.x / dt, y: dx.y / dt, }; if (Math.abs(speed.x) > 1e10 || Math.abs(speed.y) > 1e10) { speed.x = 0; speed.y = 0; } stateRef.current.mouseSpringSpeed = speed; stateRef.current.lastMouseSpringValue = c.value; stateRef.current.lastMouseSpringTime = now; }, }), mouseSpringSpeed: { x: 0, y: 0, }, bgTextureUrl: null, bgTexture: null, bgTextureRatio: 1, bgTextureType: null, bgTextureReady: false, bgVideoEls: new Map(), langName: langName, rendererBackend: 'webgl', activeRenderer: null, gpuDevice: null, }); stateRef.current.canvasInfo = canvasInfo; stateRef.current.controls = controls; stateRef.current.langName = langName; // useEffect(() => { // setLangName(controls.language[0] as keyof typeof languages); // }, [controls.language]); // console.log(controls.language); useMemo(() => { stateRef.current.blurWeights = computeGaussianKernelByRadius(controls.blurRadius); }, [controls.blurRadius]); const centerizeCanvasWindow = useCallback(() => { const ctrl = stateRef.current.canvasWindowCtrlRef; if (!ctrl) { return; } const size = ctrl.getSize(); ctrl.setMoveOffset({ x: window.innerWidth / 2 - size.width / 2, y: window.innerHeight / 2 - size.height / 2, }); }, []); useLayoutEffect(() => { const onResize = () => { centerizeCanvasWindow(); setCanvasInfo((v) => ({ ...v, dpr: window.devicePixelRatio, })); }; window.addEventListener('resize', onResize); onResize(); return () => { window.removeEventListener('resize', onResize); }; }, []); useLayoutEffect(() => { if (!canvasRef.current) { return; } canvasRef.current.width = canvasInfo.width * canvasInfo.dpr; canvasRef.current.height = canvasInfo.height * canvasInfo.dpr; }, [canvasInfo]); // Sync rendererBackend to stateRef stateRef.current.rendererBackend = rendererBackend; // Helper: create WebGL renderer const createWebGLRenderer = useCallback((canvasEl: HTMLCanvasElement) => { const gl = canvasEl.getContext('webgl2'); if (!gl) return null; return new MultiPassRenderer(canvasEl, [ { name: 'bgPass', shader: { vertex: VertexShader, fragment: FragmentBgShader } }, { name: 'vBlurPass', shader: { vertex: VertexShader, fragment: FragmentBgVblurShader }, inputs: { u_prevPassTexture: 'bgPass' } }, { name: 'hBlurPass', shader: { vertex: VertexShader, fragment: FragmentBgHblurShader }, inputs: { u_prevPassTexture: 'vBlurPass' } }, { name: 'mainPass', shader: { vertex: VertexShader, fragment: FragmentMainShader }, inputs: { u_blurredBg: 'hBlurPass', u_bg: 'bgPass' }, outputToScreen: true }, ]); }, []); // Helper: create WebGPU renderer const createWebGPURenderer = useCallback((canvasEl: HTMLCanvasElement, device: GPUDevice) => { return new GPUMultiPassRenderer(canvasEl, [ { name: 'bgPass', shader: { vertex: WgslVertex, fragment: WgslFragBg } }, { name: 'vBlurPass', shader: { vertex: WgslVertex, fragment: WgslFragVblur }, inputs: { u_prevPassTexture: 'bgPass' } }, { name: 'hBlurPass', shader: { vertex: WgslVertex, fragment: WgslFragHblur }, inputs: { u_prevPassTexture: 'vBlurPass' } }, { name: 'mainPass', shader: { vertex: WgslVertex, fragment: WgslFragMain }, inputs: { u_blurredBg: 'hBlurPass', u_bg: 'bgPass' }, outputToScreen: true }, ], device); }, []); // Effect: handle backend switch (and initial creation) // Depends on canvasKey so it re-runs after React remounts the useEffect(() => { if (!canvasRef.current) return; // Dispose old renderer if (stateRef.current.activeRenderer) { stateRef.current.activeRenderer.dispose(); stateRef.current.activeRenderer = null; } // Clear old texture (it's tied to old context) stateRef.current.bgTexture = null; stateRef.current.bgTextureReady = false; // Force the render loop to re-detect bgTextureUrl change and reload the texture. // Save and restore via a microtask so the render loop sees a null→url transition. const savedBgTextureUrl = stateRef.current.bgTextureUrl; const savedBgTextureType = stateRef.current.bgTextureType; stateRef.current.bgTextureUrl = null; stateRef.current.bgTextureType = null; const canvasEl = canvasRef.current; // Attach pointer listener on the (possibly new) canvas const onPointerMove = (e: PointerEvent) => { const ci = stateRef.current.canvasInfo; if (!ci) return; stateRef.current.canvasPointerPos = { x: (e.clientX - stateRef.current.canvasPos.x) * ci.dpr, y: (ci.height - (e.clientY - stateRef.current.canvasPos.y)) * ci.dpr, }; stateRef.current.mouseSpring.start(stateRef.current.canvasPointerPos); }; canvasEl.addEventListener('pointermove', onPointerMove); if (rendererBackend === 'webgpu' && webgpuDetect?.supported && webgpuDetect.device) { stateRef.current.gpuDevice = webgpuDetect.device; try { const renderer = createWebGPURenderer(canvasEl, webgpuDetect.device); stateRef.current.activeRenderer = renderer; } catch (e) { console.error('Failed to create WebGPU renderer, falling back to WebGL:', e); const renderer = createWebGLRenderer(canvasEl); stateRef.current.activeRenderer = renderer; } } else { stateRef.current.gpuDevice = null; const renderer = createWebGLRenderer(canvasEl); stateRef.current.activeRenderer = renderer; } // The new canvas (from key change) starts at default 300x150. // Apply current canvasInfo dimensions and force a renderer resize. const ci = stateRef.current.canvasInfo; canvasEl.width = ci.width * ci.dpr; canvasEl.height = ci.height * ci.dpr; if (stateRef.current.activeRenderer) { const w = ci.width * ci.dpr; const h = ci.height * ci.dpr; if (stateRef.current.rendererBackend === 'webgl') { const gl = canvasEl.getContext('webgl2'); gl?.viewport(0, 0, Math.round(w), Math.round(h)); } stateRef.current.activeRenderer.resize(w, h); stateRef.current.activeRenderer.setUniform('u_resolution', [w, h]); } // Restore the background texture URL so the render loop detects the // null→url transition on the next frame and reloads the texture. requestAnimationFrame(() => { stateRef.current.bgTextureUrl = savedBgTextureUrl; stateRef.current.bgTextureType = savedBgTextureType; }); return () => { canvasEl.removeEventListener('pointermove', onPointerMove); }; }, [rendererBackend, canvasKey, webgpuDetect, createWebGLRenderer, createWebGPURenderer]); useEffect(() => { let raf: number | null = null; const lastState = { canvasInfo: null as typeof canvasInfo | null, controls: null as typeof controls | null, bgTextureType: null as typeof stateRef.current.bgTextureType, bgTextureUrl: null as typeof stateRef.current.bgTextureUrl, }; const render = () => { raf = requestAnimationFrame(render); const renderer = stateRef.current.activeRenderer; if (!renderer) return; const canvasEl = canvasRef.current; if (!canvasEl) return; const backend = stateRef.current.rendererBackend; const canvasInfo = stateRef.current.canvasInfo; const textureUrl = stateRef.current.bgTextureUrl; if ( !lastState.canvasInfo || lastState.canvasInfo.width !== canvasInfo.width || lastState.canvasInfo.height !== canvasInfo.height || lastState.canvasInfo.dpr !== canvasInfo.dpr ) { if (backend === 'webgl') { const gl = canvasEl.getContext('webgl2'); if (gl) { gl.viewport(0, 0, Math.round(canvasInfo.width * canvasInfo.dpr), Math.round(canvasInfo.height * canvasInfo.dpr)); } } renderer.resize(canvasInfo.width * canvasInfo.dpr, canvasInfo.height * canvasInfo.dpr); renderer.setUniform('u_resolution', [canvasInfo.width * canvasInfo.dpr, canvasInfo.height * canvasInfo.dpr]); } // Texture management if (textureUrl !== lastState.bgTextureUrl) { if (lastState.bgTextureType === 'video') { if (lastState.controls?.bgType !== undefined) { stateRef.current.bgVideoEls.get(lastState.controls.bgType)?.pause(); } } if (!textureUrl) { if (stateRef.current.bgTexture) { if (backend === 'webgl') { const gl = canvasEl.getContext('webgl2'); gl?.deleteTexture(stateRef.current.bgTexture as WebGLTexture); } else { (stateRef.current.bgTexture as GPUTexture)?.destroy(); } stateRef.current.bgTexture = null; stateRef.current.bgTextureType = null; } } else { if (stateRef.current.bgTextureType === 'image') { const rafId = requestAnimationFrame(() => { stateRef.current.bgTextureReady = false; }); if (backend === 'webgl') { const gl = canvasEl.getContext('webgl2'); if (gl) { loadTextureFromURL(gl, textureUrl).then(({ texture, ratio }) => { if (stateRef.current.bgTextureUrl === textureUrl) { cancelAnimationFrame(rafId); stateRef.current.bgTexture = texture; stateRef.current.bgTextureRatio = ratio; stateRef.current.bgTextureReady = true; } }); } } else if (stateRef.current.gpuDevice) { gpuLoadTextureFromURL(stateRef.current.gpuDevice, textureUrl).then(({ texture, ratio }) => { if (stateRef.current.bgTextureUrl === textureUrl) { cancelAnimationFrame(rafId); stateRef.current.bgTexture = texture; stateRef.current.bgTextureRatio = ratio; stateRef.current.bgTextureReady = true; } }); } } else if (stateRef.current.bgTextureType === 'video') { stateRef.current.bgTextureReady = false; if (backend === 'webgl') { const gl = canvasEl.getContext('webgl2'); if (gl) { stateRef.current.bgTexture = createEmptyTexture(gl); } } else if (stateRef.current.gpuDevice) { stateRef.current.bgTexture = gpuCreateEmptyTexture(stateRef.current.gpuDevice); } stateRef.current.bgVideoEls.get(stateRef.current.controls.bgType)?.play(); } } } lastState.controls = stateRef.current.controls; lastState.bgTextureType = stateRef.current.bgTextureType; lastState.canvasInfo = canvasInfo; lastState.bgTextureUrl = stateRef.current.bgTextureUrl; // Video texture update if (stateRef.current.bgTextureType === 'video') { const videoEl = stateRef.current.bgVideoEls.get(stateRef.current.controls.bgType); if (stateRef.current.bgTexture && videoEl) { if (backend === 'webgl') { const gl = canvasEl.getContext('webgl2'); if (gl) { const info = updateVideoTexture(gl, stateRef.current.bgTexture as WebGLTexture, videoEl); if (info) { stateRef.current.bgTextureRatio = info.ratio; stateRef.current.bgTextureReady = true; } } } else if (stateRef.current.gpuDevice) { gpuUpdateVideoTexture(stateRef.current.gpuDevice, stateRef.current.bgTexture as GPUTexture, videoEl).then((info) => { if (info) { stateRef.current.bgTexture = info.texture; stateRef.current.bgTextureRatio = info.ratio; stateRef.current.bgTextureReady = true; } }); } } } if (backend === 'webgl') { const gl = canvasEl.getContext('webgl2'); if (gl) { gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); } } const controls = stateRef.current.controls; const mouseSpring = stateRef.current.mouseSpring.get(); const shapeSizeSpring = { x: controls.shapeWidth + (Math.abs(stateRef.current.mouseSpringSpeed.x) * controls.shapeWidth * controls.springSizeFactor) / 100, y: controls.shapeHeight + (Math.abs(stateRef.current.mouseSpringSpeed.y) * controls.shapeHeight * controls.springSizeFactor) / 100, }; renderer.setUniforms({ u_resolution: [canvasInfo.width * canvasInfo.dpr, canvasInfo.height * canvasInfo.dpr], u_dpr: canvasInfo.dpr, u_blurWeights: stateRef.current.blurWeights, u_blurRadius: stateRef.current.controls.blurRadius, u_mouse: [stateRef.current.canvasPointerPos.x, stateRef.current.canvasPointerPos.y], u_mouseSpring: [mouseSpring.x, mouseSpring.y], u_shapeWidth: shapeSizeSpring.x, u_shapeHeight: shapeSizeSpring.y, u_shapeRadius: ((Math.min(shapeSizeSpring.x, shapeSizeSpring.y) / 2) * controls.shapeRadius) / 100, u_shapeRoundness: controls.shapeRoundness, u_mergeRate: controls.mergeRate, u_glareAngle: (controls.glareAngle * Math.PI) / 180, u_showShape1: controls.showShape1 ? 1 : 0, }); renderer.render({ bgPass: { u_bgType: controls.bgType, u_bgTexture: (stateRef.current.bgTextureUrl && stateRef.current.bgTexture) ?? undefined, u_bgTextureRatio: stateRef.current.bgTextureUrl && stateRef.current.bgTexture ? stateRef.current.bgTextureRatio : undefined, u_bgTextureReady: stateRef.current.bgTextureReady ? 1 : 0, u_shadowExpand: controls.shadowExpand, u_shadowFactor: controls.shadowFactor / 100, u_shadowPosition: [-controls.shadowPosition.x, -controls.shadowPosition.y], }, mainPass: { u_tint: [ controls.tint.r / 255, controls.tint.g / 255, controls.tint.b / 255, controls.tint.a, ], u_refThickness: controls.refThickness, u_refFactor: controls.refFactor, u_refDispersion: controls.refDispersion, u_refFresnelRange: controls.refFresnelRange, u_refFresnelHardness: controls.refFresnelHardness / 100, u_refFresnelFactor: controls.refFresnelFactor / 100, u_glareRange: controls.glareRange, u_glareHardness: controls.glareHardness / 100, u_glareConvergence: controls.glareConvergence / 100, u_glareOppositeFactor: controls.glareOppositeFactor / 100, u_glareFactor: controls.glareFactor / 100, u_blurEdge: controls.blurEdge ? 1 : 0, STEP: controls.step, }, }); }; raf = requestAnimationFrame(render); return () => { if (raf) { cancelAnimationFrame(raf); } }; }, []); return ( <> {levaGlobal}
Liquid Glass Studio
{lang['ui.subtitle']}
{ setCanvasInfo({ ...size, dpr: window.devicePixelRatio, }); centerizeCanvasWindow(); }} onMove={(pos) => { stateRef.current.canvasPos = pos; }} ctrlRef={(ref) => { stateRef.current.canvasWindowCtrlRef = ref; }} >
); } export default App; ================================================ FILE: src/Controls.tsx ================================================ import { useControls, folder, Leva } from 'leva'; import { isChineseLanguage, isUzbekLanguage } from './utils'; import { LevaVectorNew } from './components/LevaVectorNew/LevaVectorNew'; // import { LevaImageUpload } from './components/LevaImageUpload/LevaImageUpload'; import { LevaContainer } from './components/LevaContainer/LevaContainer'; import { LevaCheckButtons } from './components/LevaCheckButtons'; import { useLayoutEffect, useMemo, useState } from 'react'; import languages from './utils/languages'; export const useLevaControls = ({ containerRender, rendererOptions, }: { containerRender: { bgType: (props: { value: number; setValue: (v: number) => void }) => React.ReactNode; }; rendererOptions?: { webgpuSupported: boolean; webgpuUnavailableReason?: string; onRendererChange?: (backend: 'webgl' | 'webgpu') => void; }; }) => { const [langName, setLangName] = useState( isChineseLanguage() ? 'zh-CN' : isUzbekLanguage() ? 'uz-UZ' : 'en-US', ); const lang = useMemo(() => { return languages[langName]; }, [langName]); const [controls, controlsAPI] = useControls( () => ({ ['basicSettings']: folder({ renderer: LevaCheckButtons({ label: lang['editor.renderer'], selected: ['webgl'], options: [ { value: 'webgl', label: 'WebGL' }, { value: 'webgpu', label: 'WebGPU', disabled: !rendererOptions?.webgpuSupported, title: !rendererOptions?.webgpuSupported ? (rendererOptions?.webgpuUnavailableReason || lang['editor.rendererWebGPUUnavailable']) : undefined, }, ], singleMode: true, onClick: (v) => { rendererOptions?.onRendererChange?.(v[0] as 'webgl' | 'webgpu'); }, }), language: LevaCheckButtons({ label: lang['editor.language'], selected: [langName], options: !isChineseLanguage() ? [ { value: 'en-US', label: 'English' }, { value: 'zh-CN', label: '简体中文' }, { value: 'uz-UZ', label: "O'zbekcha" }, ] : [ { value: 'zh-CN', label: '简体中文' }, { value: 'en-US', label: 'English' }, { value: 'uz-UZ', label: "O'zbekcha" }, ], onClick: (v) => { setLangName((v as (keyof typeof languages)[])[0]); }, singleMode: true, }), }), refThickness: { label: lang['editor.refThickness'], min: 1, max: 80, step: 0.01, value: 20, }, refFactor: { label: lang['editor.refFactor'], min: 1, max: 4, step: 0.01, value: 1.4, }, refDispersion: { label: lang['editor.refDispersion'], min: 0, max: 50, step: 0.01, value: 7, }, refFresnelRange: { label: lang['editor.refFresnelRange'], min: 0, max: 100, step: 0.01, value: 30, }, refFresnelHardness: { label: lang['editor.refFresnelHardness'], min: 0, max: 100, step: 0.01, value: 20, }, refFresnelFactor: { label: lang['editor.refFresnelFactor'], min: 0, max: 100, step: 0.01, value: 20, }, glareRange: { label: lang['editor.glareRange'], min: 0, max: 100, step: 0.01, value: 30, }, glareHardness: { label: lang['editor.glareHardness'], min: 0, max: 100, step: 0.01, value: 20, }, glareFactor: { label: lang['editor.glareFactor'], min: 0, max: 120, step: 0.01, value: 90, }, glareConvergence: { label: lang['editor.glareConvergence'], min: 0, max: 100, step: 0.01, value: 50, }, glareOppositeFactor: { label: lang['editor.glareOppositeFactor'], min: 0, max: 100, step: 0.01, value: 80, }, glareAngle: { label: lang['editor.glareAngle'], min: -180, max: 180, step: 0.01, value: -45, }, blurRadius: { label: lang['editor.blurRadius'], min: 1, max: 200, step: 1, value: 1, }, blurEdge: { label: lang['editor.blurEdge'], value: true, }, tint: { label: lang['editor.tint'], value: { r: 255, b: 255, g: 255, a: 0 }, }, shadowExpand: { label: lang['editor.shadowExpand'], min: 2, max: 100, step: 0.01, value: 25, }, shadowFactor: { label: lang['editor.shadowFactor'], min: 0, max: 100, step: 0.01, value: 15, }, shadowPosition: LevaVectorNew({ label: lang['editor.shadowPosition'], x: 0, y: -10, xMax: 20, yMax: 20, }), bgType: LevaContainer({ label: lang['editor.bgType'], contentValue: 0, content: containerRender.bgType, }), // customBgImage: LevaImageUpload({ // label: lang['editor.customBgImage'], // file: undefined, // // disabled: renderProps.isRendering, // // alphaPatternColorA: '#bbb', // // alphaPatternColorB: '#eee', // }), ['shapeSettings']: folder({ shapeWidth: { label: lang['editor.shapeWidth'], min: 20, max: 800, step: 1, value: 200, }, shapeHeight: { label: lang['editor.shapeHeight'], min: 20, max: 800, step: 1, value: 200, }, shapeRadius: { label: lang['editor.shapeRadius'], min: 1, max: 100, step: 0.1, value: 80, }, shapeRoundness: { label: lang['editor.shapeRoundness'], min: 2, max: 7, step: 0.01, value: 5, }, mergeRate: { label: lang['editor.mergeRate'], min: 0, max: 0.3, step: 0.01, value: 0.05, }, showShape1: { label: lang['editor.showShape1'], value: true, }, }), animationSettings: folder({ springSizeFactor: { label: lang['editor.springSizeFactor'], min: 0, max: 50, step: 0.01, value: 10, }, }, { collapsed: true }), ['debugSettings']: folder({ step: { label: 'Show Step', value: 9, min: 0, max: 9, step: 1, }, }, { collapsed: true }), }), [langName, rendererOptions?.webgpuSupported], ); useLayoutEffect(() => { const levaRoot = document.querySelector('#root>[class^=leva]'); if (!levaRoot) { return; } setTimeout(() => { const controlEls = (levaRoot.lastChild as HTMLDivElement).querySelectorAll('&>div>div'); controlEls.forEach((el) => { const ctrlEl = el as HTMLDivElement; const styleStr = ctrlEl.getAttribute('style'); if (styleStr && styleStr.includes('folder')) { // get title str: const titleEl = ctrlEl.querySelector('&>div>svg+div') as HTMLDivElement; if (!titleEl) { return; } const titleStr = titleEl.innerText; ctrlEl.style.setProperty( '--i18n-name', `"${lang[`editor.${titleStr}` as keyof Omit] ?? titleStr}"`, ); ctrlEl.dataset.levaFolder = '1'; } }); }, 0); }, [lang]); const levaGlobal = ( ); return { controls, controlsAPI, lang, langName, levaGlobal, }; }; ================================================ FILE: src/components/LevaButton/LevaButton.scss ================================================ .leva-button-custom { padding: 0px 8px; border-radius: var(--leva-radii-sm); background: var(--leva-colors-elevation3); display: inline-flex; border: none; color: var(--leva-colors-highlight2); height: 20px; &:disabled { opacity: 0.6; cursor: not-allowed; } &--intent { &-danger { background: #7e1818; color: #ffbaba; } &-primary { background: var(--leva-colors-accent1); color: #b9d3fe; } } &:active, &--active { color: var(--leva-colors-highlight3); } } ================================================ FILE: src/components/LevaButton/LevaButton.tsx ================================================ import clsx from 'clsx'; import React from 'react'; import './LevaButton.scss'; export type LevaButtonProps = { children: React.ReactNode; active?: boolean; intent?: 'normal' | 'primary' | 'danger' | 'warning'; } & React.DetailedHTMLProps, HTMLButtonElement>; export const LevaButton = ({ children, className, intent = 'normal', active, ...rest }: LevaButtonProps) => { return ( ); }; ================================================ FILE: src/components/LevaButton/index.ts ================================================ export { LevaButton } from './LevaButton'; ================================================ FILE: src/components/LevaCheckButtons/LevaCheckButtons.scss ================================================ .leva-check-buttons { &__button-group { display: flex; align-items: center; .leva-button-custom { position: relative; } .leva-button-custom:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; } .leva-button-custom:last-child { border-top-left-radius: 0; border-bottom-left-radius: 0; } .leva-button-custom:not(:last-child):not(:first-child) { border-radius: 0; } .leva-button-custom:not(:last-child) { &::after { content: ''; display: block; position: absolute; right: -0.5px; top: 0; height: 100%; border-right: 1px solid rgba(white, 0.1); z-index: 1; } } } } ================================================ FILE: src/components/LevaCheckButtons/LevaCheckButtons.tsx ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ // import { CSSProperties, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './LevaCheckButtons.scss'; import { createPlugin, useInputContext, type LevaInputProps, Components } from 'leva/plugin'; // import clsx from 'clsx'; import { LevaButton } from '../LevaButton'; const { Row, Label } = Components; type CheckButtonsSettings = { options: { value: string; label?: string; disabled?: boolean; title?: string; }[]; singleMode?: boolean; onClick?: (v: string[]) => void; } type CheckButtonsValueType = string[]; type CheckButtonsProps = CheckButtonsSettings & { selected: CheckButtonsValueType }; type CheckButtonsLevaProps = LevaInputProps; function LevaCheckButtonsComponent() { const props = useInputContext(); const { label, displayValue, onUpdate, onChange, settings, value } = props; const settingsRequired = settings as Required; const { options, singleMode } = settingsRequired; return (
{options.map((option) => { const selectedIdx = value.indexOf(option.value); const isSelected = selectedIdx >= 0; return ( { if (option.disabled) { return; } if (singleMode) { const newValue = [option.value]; onUpdate(newValue); settings.onClick?.(newValue); return; } if (isSelected) { const newValue = value.slice(); newValue.splice(selectedIdx, 1); onUpdate(newValue); settings.onClick?.(newValue); } else { const newValue = value.concat(option.value); onUpdate(newValue); settings.onClick?.(newValue); } }} > {option.label ?? option.value} ); })}
); } const normalize = ({ selected, ...settings }: CheckButtonsProps): { value: CheckButtonsValueType; settings: CheckButtonsSettings; } => { const { options, singleMode, ...rest } = settings ?? {}; return { value: selected, settings: { options: options ?? [], singleMode: singleMode ?? false, ...rest, }, }; }; const sanitize = ( value: CheckButtonsValueType, settings: CheckButtonsSettings, lastValue: CheckButtonsValueType, path: string, ): CheckButtonsValueType => { return value; }; const format = (v: CheckButtonsValueType, settings: CheckButtonsSettings) => { return v; }; export const LevaCheckButtons = createPlugin({ sanitize, format, normalize, component: LevaCheckButtonsComponent, }); ================================================ FILE: src/components/LevaCheckButtons/index.ts ================================================ export { LevaCheckButtons } from './LevaCheckButtons'; ================================================ FILE: src/components/LevaContainer/LevaContainer.scss ================================================ .leva-container { $s: &; &.leva-container--with-label { grid-template-columns: var(--label-width) var(--content-width, var(--leva-sizes-controlWidth)); } &#{$s}--with-label#{$s}--label-vertical-align-top { div:nth-child(1) { align-items: flex-start; padding-top: 4px; } } } ================================================ FILE: src/components/LevaContainer/LevaContainer.tsx ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { createPlugin, useInputContext, type LevaInputProps, Components } from 'leva/plugin'; import './LevaContainer.scss'; import clsx from 'clsx'; import { useState, type CSSProperties } from 'react'; const { Row, Label, String } = Components; type ContainerSettings = { showLabel?: boolean; labelRow?: boolean; content?: React.ReactNode | ((props: { value: any; setValue: (v: any) => void }) => React.ReactNode); className?: string; style?: React.CSSProperties; contentWrapperClassName?: string; contentWrapperStyle?: React.CSSProperties; labelVerticalAlign?: 'top' | 'center'; labelWidth?: 'auto' | number; contentWidth?: string; }; type ContainerValueType = { contentValue: any }; type ContainerProps = ContainerValueType & ContainerSettings; type ContainerInputProps = LevaInputProps; function ContainerComponent() { const props = useInputContext(); const { label, displayValue, onUpdate, onChange, settings, value } = props; return ( {settings.showLabel ? : null}
{typeof settings.content === 'function' ? settings.content?.({ value, setValue: (v) => { onUpdate(v); }, }) : settings.content}
); } const normalize = ({ contentValue, ...settings }: ContainerProps) => { return { value: contentValue, settings: { showLabel: true, labelRow: false, labelVerticalAlign: 'top' as ContainerSettings['labelVerticalAlign'], content: null, labelWidth: 'auto' as ContainerSettings['labelWidth'], contentWidth: 'var(--leva-sizes-controlWidth)', ...settings, }, }; }; const sanitize = (v: ContainerValueType, settings: ContainerSettings): ContainerValueType => { return v; }; const format = (v: ContainerValueType) => { return v; }; export const LevaContainer = createPlugin({ sanitize, format, normalize, component: ContainerComponent, }); ================================================ FILE: src/components/LevaContainer/index.ts ================================================ ================================================ FILE: src/components/LevaImageUpload/LevaImageUpload.scss ================================================ .leva-image-upload { $s: &; div:first-child { padding-top: 4px; align-items: flex-start; } &--disabled { opacity: 0.6; pointer-events: none; } &__preview { width: var(--preview-width); height: var(--preview-height); border-radius: 4px; outline: 1px solid var(--leva-colors-highlight1); overflow: hidden; position: relative; cursor: pointer; &::before { content: ''; position: absolute; inset: 0; background-color: var(--alpha-pattern-color-b); background-image: repeating-linear-gradient( 45deg, var(--alpha-pattern-color-a) 25%, transparent 25%, transparent 75%, var(--alpha-pattern-color-a) 75%, var(--alpha-pattern-color-a) ), repeating-linear-gradient( 45deg, var(--alpha-pattern-color-a) 25%, var(--alpha-pattern-color-b) 25%, var(--alpha-pattern-color-b) 75%, var(--alpha-pattern-color-a) 75%, var(--alpha-pattern-color-a) ); background-position: 0 0, calc(var(--alpha-pattern-size) / 2) calc(var(--alpha-pattern-size) / 2); background-size: var(--alpha-pattern-size) var(--alpha-pattern-size); } &-img { width: 100%; height: 100%; object-fit: contain; position: relative; } } &__clearbt { position: absolute; opacity: 0; color: white; background: rgba(0, 0, 0, 0.5); padding: 2px; border-radius: 100%; right: 2px; top: 2px; width: 20px; height: 20px; cursor: pointer; } &__preview:hover { #{$s}__clearbt { opacity: 1; } } // input[type='file'] { // cursor: pointer; // height: 100%; // display: flex; // align-items: center; // border-radius: var(--leva-radii-sm); // line-height: 18px; // padding: 4px; // &::file-selector-button { // display: none; // } // &:hover { // background: var(--leva-colors-elevation3); // } // } } ================================================ FILE: src/components/LevaImageUpload/LevaImageUpload.tsx ================================================ import { type CSSProperties, useEffect, useMemo, useRef } from 'react'; import './LevaImageUpload.scss'; import { createPlugin, useInputContext, type LevaInputProps, Components } from 'leva/plugin'; import DeleteIcon from '@mui/icons-material/Delete'; import clsx from 'clsx'; const { Row, Label, String } = Components; type ImageUploadSettings = { accept?: string; previewSize?: number | [number, number]; displayDisabled?: boolean; clearable?: boolean; alphaPatternSize?: number; alphaPatternColorA?: string; alphaPatternColorB?: string; }; type ImageUploadValueType = { file?: string }; type ImageUploadProps = ImageUploadValueType & ImageUploadSettings; type ImageUploadLevaProps = LevaInputProps; function GreenOrBlue() { const props = useInputContext(); const { label, displayValue, onUpdate, onChange, settings, value, disabled } = props; const stateRef = useRef<{ imageObjURL: null | string; }>({ imageObjURL: null, }); const inputRef = useRef(null); const { previewSize, accept, alphaPatternSize, alphaPatternColorA, alphaPatternColorB, displayDisabled, clearable, } = settings; const previewSizeNorm = typeof previewSize === 'number' ? ([previewSize, previewSize] as const) : (previewSize as [number, number]); return (
{ const file = e.target.files?.[0]; if (!file) { return; } if (stateRef.current.imageObjURL) { URL.revokeObjectURL(stateRef.current.imageObjURL); } stateRef.current.imageObjURL = URL.createObjectURL(file); e.target.value = ''; onUpdate(stateRef.current.imageObjURL); onChange(stateRef.current.imageObjURL); }} >
{ if (!inputRef.current) { return; } inputRef.current.click(); }} > {value?.file ? ( ) : null} {value?.file && clearable && { e.stopPropagation(); onUpdate({ file: undefined }); onChange({ file: undefined }); }}>}
); } const normalize = ({ file = undefined, ...settings }: ImageUploadProps): { value: { file?: string }; settings: ImageUploadSettings } => { return { value: { file }, settings: { displayDisabled: false, accept: 'image/*', clearable: true, previewSize: 60, alphaPatternSize: 20, alphaPatternColorA: '#5b5f6b', alphaPatternColorB: 'var(--leva-colors-elevation1)', ...settings, }, }; }; const sanitize = (v?: any): ImageUploadValueType => { // if (!['green', 'blue', 'lightgreen', 'lightblue'].includes(v)) throw Error('Invalid value') // // @ts-ignore // const [, isLight, color] = v.match(/(light)?(.*)/) if (typeof v === 'string') { return { file: v } } if ('file' in v) { return v; } return { file: undefined } }; const format = (v: ImageUploadValueType) => { if (!v.file) { return undefined; } return { file: v.file }; }; export const LevaImageUpload = createPlugin({ sanitize, format, normalize, component: GreenOrBlue, }); ================================================ FILE: src/components/LevaVectorNew/LevaVectorNew.scss ================================================ .leva-vector-new { &__input-wrapper { display: flex; gap: var(--leva-space-colGap); div:nth-child(2), div:nth-child(3) { flex: 1; } } &__joystick { position: relative; width: var(--leva-sizes-rowHeight); height: var(--leva-sizes-rowHeight); background-color: var(--leva-colors-elevation3); border-radius: 100%; cursor: crosshair; &::after { content: ''; display: block; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 4px; height: 4px; border-radius: 100%; background-color: var(--leva-colors-accent2); } &-pop { width: var(--joystick-size); height: var(--joystick-size); opacity: 1; position: fixed; left: var(--root-center-x); top: var(--root-center-y); z-index: 999999; transform: translate(-50%, -50%); cursor: crosshair; &-bg { position: absolute; inset: 0; background-color: var(--leva-colors-elevation1); border: 1px solid var(--leva-colors-highlight1); box-shadow: var(--leva-shadows-level2); border-radius: 100%; } canvas { transform-origin: 0 0; } &-value { font-size: 12px; font-family: monospace; color: var(--leva-colors-highlight3); background-color: rgba(black, 0.5); backdrop-filter: blur(2px); padding: 4px 8px; position: absolute; top: 0; left: 50%; width: max-content; border-radius: var(--leva-radii-sm); transform: translate(-50%, calc(-100% - 6px)); } } } } ================================================ FILE: src/components/LevaVectorNew/LevaVectorNew.tsx ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'; import './LevaVectorNew.scss'; import { createPlugin, useInputContext, type LevaInputProps, Components } from 'leva/plugin'; import clsx from 'clsx'; const { Row, Label, Number: NumberComp, Portal } = Components; type VectorNewSettings = { xLabel?: string; yLabel?: string; xMax?: number; yMax?: number; step?: number; precision?: number; joystickSize?: number; confine?: 'circle'; showVectorLine?: boolean; }; type VectorNewValueType = { x: number; y: number }; type VectorNewProps = VectorNewValueType & VectorNewSettings; type VectorNewLevaProps = LevaInputProps; type JoyStickProps = { showVectorLine?: boolean; size?: number; value: VectorNewValueType; onUpdate: (value: VectorNewValueType) => void; }; function Joystick({ showVectorLine = true, size = 130, onUpdate, value, settings, }: JoyStickProps & { settings: Omit, keyof JoyStickProps>; }) { const [showPop, setShowPop] = useState(false); const canvasRef = useRef(null); const rootRef = useRef(null); const [rootCenter, setRootCenter] = useState({ x: 0, y: 0, }); const stateRef = useRef<{ dpr: number; size: number; showVectorLine: boolean; settings: typeof settings; value: VectorNewValueType; updateCanvas: () => void; }>({ dpr: window.devicePixelRatio, updateCanvas: () => { }, value, size, showVectorLine, settings, }); stateRef.current.size = size; stateRef.current.showVectorLine = showVectorLine; stateRef.current.settings = settings; stateRef.current.value = value; useLayoutEffect(() => { if (showPop) { stateRef.current.dpr = window.devicePixelRatio; let computedStyle = null as CSSStyleDeclaration | null; stateRef.current.updateCanvas = () => { if (!canvasRef.current) { return; } const ctx = canvasRef.current.getContext('2d'); if (!ctx) { return; } computedStyle = getComputedStyle(canvasRef.current); // draw const sizeHiDPI = stateRef.current.dpr * stateRef.current.size; const pad = 30; const sizeHiDPIPad = sizeHiDPI + pad * 2; const dpr = stateRef.current.dpr; canvasRef.current.width = sizeHiDPIPad; canvasRef.current.height = sizeHiDPIPad; canvasRef.current.style.transform = `scale(${1 / dpr}) translate(${-pad}px, ${-pad}px)`; const accentColor = computedStyle.getPropertyValue('--leva-colors-accent2') ?? '#007bff'; const highlightColor = computedStyle.getPropertyValue('--leva-colors-highlight2') ?? '#8c92a4'; ctx.save(); ctx.translate(pad, pad); ctx.save(); ctx.clearRect(0, 0, sizeHiDPI, sizeHiDPI); ctx.strokeStyle = highlightColor; ctx.setLineDash([4 * dpr, 4 * dpr]); ctx.beginPath(); ctx.arc(sizeHiDPI / 2, sizeHiDPI / 2, sizeHiDPI / 8, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.beginPath(); ctx.arc(sizeHiDPI / 2, sizeHiDPI / 2, sizeHiDPI / 4, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([4 * dpr, 4 * dpr]); ctx.beginPath(); ctx.arc(sizeHiDPI / 2, sizeHiDPI / 2, (sizeHiDPI * 3) / 8, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); // vector point const valuePoint = { x: (stateRef.current.value.x / (2 * stateRef.current.settings.xMax)) * sizeHiDPI + sizeHiDPI / 2, y: (stateRef.current.value.y / (-2 * stateRef.current.settings.yMax)) * sizeHiDPI + sizeHiDPI / 2, }; ctx.save(); ctx.beginPath(); ctx.fillStyle = accentColor; ctx.arc(valuePoint.x, valuePoint.y, 6 * dpr, 0, Math.PI * 2); ctx.closePath(); ctx.fill(); ctx.restore(); // vector line ctx.save(); ctx.strokeStyle = accentColor; ctx.lineWidth = 2 * dpr; ctx.beginPath(); ctx.moveTo(sizeHiDPI / 2, sizeHiDPI / 2); ctx.lineTo(valuePoint.x, valuePoint.y); ctx.stroke(); ctx.restore(); // vector center ctx.save(); ctx.beginPath(); ctx.fillStyle = accentColor; ctx.arc(sizeHiDPI / 2, sizeHiDPI / 2, 3 * dpr, 0, Math.PI * 2); ctx.closePath(); ctx.fill(); ctx.restore(); ctx.restore(); }; stateRef.current.updateCanvas(); return () => { stateRef.current.updateCanvas = () => { }; }; } }, [showPop]); return (
{ if (!rootRef.current) { return; } const rootRect = rootRef.current.getBoundingClientRect(); const rootCenter = { x: rootRect.left + rootRect.width / 2, y: rootRect.top + rootRect.height / 2, }; setRootCenter(rootCenter); setShowPop(true); e.preventDefault(); const onPointerMove = (e: PointerEvent) => { e.preventDefault(); stateRef.current.updateCanvas(); const pos = { x: e.clientX, y: e.clientY, }; const delta = { x: pos.x - rootCenter.x, y: pos.y - rootCenter.y, }; const value = { x: settings.xMax * (delta.x / size) * 2, y: settings.yMax * (delta.y / size) * -2, }; onUpdate(value); }; const onPointerUp = () => { setShowPop(false); document.body.removeEventListener('pointermove', onPointerMove); document.body.removeEventListener('pointerup', onPointerUp); document.body.removeEventListener('pointercancel', onPointerUp); }; document.body.addEventListener('pointermove', onPointerMove); document.body.addEventListener('pointerup', onPointerUp); document.body.addEventListener('pointercancel', onPointerUp); }} ref={rootRef} > {showPop && (
{`${settings.xLabel}: ${value.x}, ${settings.yLabel}: ${value.y}`}
)}
); } function LevaVectorNewComponent() { const props = useInputContext(); const { label, displayValue, onUpdate, onChange, settings, value } = props; const settingsRequired = settings as Required; const { step, showVectorLine, joystickSize } = settingsRequired; return (
{(['x', 'y'] as const).map((k) => { return ( { const lastValue = value[k]; let currentValue = lastValue; if (typeof v === 'function') { currentValue = v(lastValue); } else { currentValue = v; } const newValue = { ...value, [k]: currentValue, }; onUpdate(newValue); }} onChange={(v) => { const newValue = { ...value, [k]: v, }; onChange(newValue); }} settings={{ step: step!, min: -Math.abs(settings[`${k}Max`]!), max: Math.abs(settings[`${k}Max`]!), pad: 0, initialValue: value[k], }} label={settings[`${k}Label`]!} > ); })}
); } const normalize = ({ x, y, ...settings }: VectorNewProps): { value: VectorNewValueType; settings: VectorNewSettings } => { return { value: { x, y }, settings: { xMax: 1, yMax: 1, xLabel: 'x', yLabel: 'y', step: 0.01, precision: 2, showVectorLine: true, joystickSize: 130, confine: 'circle', ...settings, }, }; }; const sanitize = ( value: VectorNewValueType, settings: VectorNewSettings, lastValue: VectorNewValueType, ): VectorNewValueType => { const precision = parseInt(settings.precision! as any); let normalized = (['x', 'y'] as const).map((k) => { let v = value[k]; if (isNaN(v)) { throw Error('Invalid value'); } const max = settings[`${k}Max`]!; if (Math.abs(v) > Math.abs(max)) { v = (v < 0 ? -1 : 1) * max; } v = Math.round(v * Math.pow(10, precision)) / Math.pow(10, precision); return v; }); if (settings.confine === 'circle') { const length = Math.sqrt( Math.pow(normalized[0] / settings.xMax!, 2) + Math.pow(normalized[1] / settings.yMax!, 2) ); if (length > 1) { const scale = 1 / length; normalized = normalized.map((_, idx) => { let v = normalized[idx]; v *= scale; v = Math.round(v * Math.pow(10, precision)) / Math.pow(10, precision); return v; }) } } if (lastValue.x === normalized[0] && lastValue.y === normalized[1]) { throw Error('Unchanged'); } return { x: normalized[0], y: normalized[1], }; }; const format = (v: VectorNewValueType) => { return { x: v.x, y: v.y, }; }; export const LevaVectorNew = createPlugin({ sanitize, format, normalize, component: LevaVectorNewComponent, }); ================================================ FILE: src/components/PresetControls/PresetControls.module.scss ================================================ .presetControls { display: flex; gap: 8px; margin-top: 8px; position: absolute; } ================================================ FILE: src/components/PresetControls/PresetControls.tsx ================================================ import { useRef } from 'react'; import { LevaButton } from '../LevaButton/LevaButton'; import { exportPreset, importPreset } from '../../utils/presetUtils'; import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'; import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined'; import styles from './PresetControls.module.scss'; import { type useLevaControls } from '../../Controls'; export interface PresetControlsProps { controls: ReturnType['controls']; controlsAPI: ReturnType['controlsAPI']; lang: ReturnType['lang']; } export const PresetControls = ({ controls, controlsAPI, lang }: PresetControlsProps) => { const fileInputRef = useRef(null); const handleExport = () => { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); exportPreset(controls, `liquid-glass-${timestamp}.json`); }; const handleImportClick = () => { fileInputRef.current?.click(); }; const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; try { const preset = await importPreset(file); console.log('Preset loaded:', preset); if (typeof controlsAPI === 'function') { try { controlsAPI(preset.controls); } catch (err) { console.error(`Error setting preset values with leva:`, err); } } else { console.error('controlsAPI is not a function. Import may fail.', controlsAPI); } alert(lang['editor.importSuccessMessage']); } catch (err) { alert(lang['editor.importFailedMessage'](err instanceof Error ? err.message : 'Unknown error')); } if (fileInputRef.current) { fileInputRef.current.value = ''; } }; return (
{lang['editor.export'] || 'Export'} {lang['editor.import'] || 'Import'}
); }; ================================================ FILE: src/components/ResizableWindow/ResizableWindow.module.scss ================================================ .resizable { outline: 1px solid rgba(black, 0.1); box-shadow: 0 2px 7px rgba(0, 0, 0, 0.5); // margin-left: 20px; // margin-top: 20px; .resizeHandle, .moveHandle { z-index: 3; transition: all 150ms; opacity: 0; left: calc(100% - 12px); top: calc(100% - 12px); position: absolute; transform: translate(-50%, -50%) scale(0.1); display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 5px rgba(black, 0.3); } .resizeHandle { cursor: nwse-resize; :global(.MuiSvgIcon-root) { transform: rotate(-45deg); } } .moveHandle { cursor: move; :global(.MuiSvgIcon-root) { transform: rotate(-45deg) scale(0.75); } } &:hover { .resizeHandle, .moveHandle { opacity: 1; background-color: #50565f; color: white; width: 24px; height: 24px; border-radius: 100%; outline: 1px solid rgba(#83888e, 1); transform: translate(-50%, -50%); } } &.disableResize:not(.disableMove) { .handle { pointer-events: none; } .moveHandle { pointer-events: all; } } } .handle { z-index: 1; &Corner { z-index: 2; } &TopLeft { transform: translate( calc(-1 * var(--extend-bound-left, 0px)), calc(-1 * var(--extend-bound-top, 0px)) ); } &TopRight { transform: translate(var(--extend-bound-right, 0px), calc(-1 * var(--extend-bound-top, 0px))); } &BottomLeft { transform: translate(calc(-1 * var(--extend-bound-left, 0px)), var(--extend-bound-bottom, 0px)); } &BottomRight { transform: translate(var(--extend-bound-right, 0px), var(--extend-bound-bottom, 0px)); } &Left { height: calc(100% + var(--extend-bound-top, 0px) + var(--extend-bound-bottom, 0px)) !important; transform: translate( calc(-1 * var(--extend-bound-left, 0px)), calc(-1 * var(--extend-bound-top, 0px)) ); } &Right { height: calc(100% + var(--extend-bound-top, 0px) + var(--extend-bound-bottom, 0px)) !important; transform: translate(var(--extend-bound-right, 0px), calc(-1 * var(--extend-bound-top, 0px))); } &Top { width: calc(100% + var(--extend-bound-left, 0px) + var(--extend-bound-right, 0px)) !important; transform: translate( calc(-1 * var(--extend-bound-left, 0px)), calc(-1 * var(--extend-bound-top, 0px)) ); } &Bottom { width: calc(100% + var(--extend-bound-left, 0px) + var(--extend-bound-right, 0px)) !important; transform: translate(calc(-1 * var(--extend-bound-left, 0px)), var(--extend-bound-bottom, 0px)); } } ================================================ FILE: src/components/ResizableWindow/ResizableWindow.tsx ================================================ import clsx from 'clsx'; import { Resizable } from 're-resizable'; import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap'; import { type CSSProperties, type ReactNode, type Ref, useEffect, useMemo, useRef } from 'react'; import styles from './ResizableWindow.module.scss'; export type ResizeWindowCtrlRefType = { setMoveOffset: (offset: { x: number; y: number }) => void; getSize: () => { width: number; height: number }; }; type Props = { className?: string; style?: CSSProperties; size?: { width: number; height: number }; onResize?: (size: { width: number; height: number }) => void; onMove?: ( pos: { x: number; y: number }, options: { preventDefault: () => void; }, ) => void; ctrlRef?: Ref; extendBound?: { top?: number; bottom?: number; left?: number; right?: number; }; resizableRef?: Ref; maxWidth?: number; maxHeight?: number; minWidth?: number; minHeight?: number; lockAspectRatio?: boolean; disableMove?: boolean; showMoveHandle?: boolean; disableResize?: boolean; showResizeHandle?: boolean; children?: ReactNode; }; export const ResizableWindow = ({ className, style, size = { width: 100, height: 100 }, extendBound, onResize, onMove, resizableRef, ctrlRef, maxWidth, maxHeight, minWidth, minHeight, lockAspectRatio, disableMove, showMoveHandle = true, disableResize, showResizeHandle = true, children, }: Props) => { const stateRef = useRef<{ resizableRef: Resizable | null; canvasSizeStart: { width: number; height: number; }; canvasMoveOffset: { x: number; y: number }; size: typeof size; onMove: typeof onMove; disableMove: typeof disableMove; }>({ resizableRef: null, canvasSizeStart: { width: 0, height: 0, }, canvasMoveOffset: { x: 0, y: 0, }, size, onMove, disableMove, }); stateRef.current.size = size; stateRef.current.onMove = onMove; stateRef.current.disableMove = disableMove; const extendBoundStyle = useMemo(() => { return Object.keys(extendBound ?? {}).reduce((pv, cv) => { (pv as any)[`--extend-bound-${cv}`] = `${(extendBound as any)[cv]}px`; return pv; }, {} as CSSProperties); }, [extendBound]); useEffect(() => { const rootEl = stateRef.current.resizableRef?.resizable; if (!rootEl) { return; } const onPointerDown = (e: PointerEvent) => { let el = e.target as HTMLElement; let moveable = false; while (el && el !== rootEl) { if (el.dataset.notMoveHandle) { moveable = false; break; } if (el.dataset.moveHandle) { moveable = true; break; } el = el.parentNode as HTMLElement; } if (!moveable || disableMove) { return; } e.stopPropagation(); e.preventDefault(); if (stateRef.current.disableMove) { return; } const startPoint = { x: e.clientX, y: e.clientY, }; const startOffset = { ...stateRef.current.canvasMoveOffset, }; const onPointerMove = (e: PointerEvent) => { const offsetNew = { x: e.clientX - startPoint.x + startOffset.x, y: e.clientY - startPoint.y + startOffset.y, }; let defaultPrevented = false; stateRef.current.onMove?.(offsetNew, { preventDefault: () => { defaultPrevented = true; }, }); if (!defaultPrevented) { rootEl.style.transform = `translate(${offsetNew.x}px, ${offsetNew.y}px)`; stateRef.current.canvasMoveOffset = offsetNew; } }; const onPointerUp = (e: PointerEvent) => { onPointerMove(e); document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); }; document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); }; rootEl.addEventListener('pointerdown', onPointerDown); return () => { rootEl.removeEventListener('pointerdown', onPointerDown); }; }, []); return ( { stateRef.current.resizableRef = ref; if (typeof resizableRef === 'function') { resizableRef(ref); } else if (resizableRef) { resizableRef.current = ref; } const ctrl: ResizeWindowCtrlRefType = { getSize: () => { return { ...stateRef.current.size, }; }, setMoveOffset: (offset) => { const el = stateRef.current.resizableRef?.resizable; if (!el) { return; } let defaultPrevented = false; stateRef.current.onMove?.(offset, { preventDefault() { defaultPrevented = true; } }); if (!defaultPrevented) { el.style.transform = `translate(${offset.x}px, ${offset.y}px)`; stateRef.current.canvasMoveOffset = offset; } }, }; if (typeof ctrlRef === 'function') { ctrlRef(ctrl); } else if (ctrlRef) { ctrlRef.current = ctrl; } }} maxWidth={maxWidth} maxHeight={maxHeight} minWidth={minWidth} minHeight={minHeight} lockAspectRatio={lockAspectRatio} onResizeStart={() => { stateRef.current.canvasSizeStart = size; }} onResize={(_, _1, _2, delta) => { if (disableResize) { return; } onResize?.({ width: stateRef.current.canvasSizeStart.width + delta.width, height: stateRef.current.canvasSizeStart.height + delta.height, }); }} handleComponent={{ bottomRight: !disableResize && showResizeHandle ? (
) : undefined, topLeft: !disableMove && showMoveHandle ? (
) : undefined, }} > {children}
); }; ================================================ FILE: src/components/ResizableWindow/index.tsx ================================================ export { ResizableWindow } from './ResizableWindow'; ================================================ FILE: src/index.scss ================================================ @import '../node_modules/modern-normalize/modern-normalize.css'; body { --bg-color: #2a2a33; --bg-color-fore: #474757; background-color: #ffffff; background: radial-gradient( circle, transparent 20%, var(--bg-color) 20%, var(--bg-color) 80%, transparent 80%, transparent ), radial-gradient( circle, transparent 20%, var(--bg-color) 20%, var(--bg-color) 80%, transparent 80%, transparent ) 25px 25px, linear-gradient(var(--bg-color-fore) 2px, transparent 2px) 0 -1px, linear-gradient(90deg, var(--bg-color-fore) 2px, var(--bg-color) 2px) -1px 0; background-size: 50px 50px, 50px 50px, 25px 25px, 25px 25px; } ================================================ FILE: src/main.tsx ================================================ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.scss' import App from './App.tsx' createRoot(document.getElementById('root')!).render( , ) ================================================ FILE: src/shaders/fragment-bg-hblur.glsl ================================================ #version 300 es precision highp float; #define MAX_BLUR_RADIUS (200) in vec2 v_uv; uniform sampler2D u_prevPassTexture; uniform vec2 u_resolution; uniform int u_blurRadius; uniform float u_blurWeights[MAX_BLUR_RADIUS + 1]; out vec4 fragColor; void main() { vec2 texelSize = 1.0 / u_resolution; vec4 color = texture(u_prevPassTexture, v_uv) * u_blurWeights[0]; for (int i = 1; i <= u_blurRadius; ++i) { float w = u_blurWeights[i]; vec2 offset = vec2(float(i)) * texelSize; color += texture(u_prevPassTexture, v_uv + vec2(0.0, offset.y)) * w; color += texture(u_prevPassTexture, v_uv - vec2(0.0, offset.y)) * w; } fragColor = color; } ================================================ FILE: src/shaders/fragment-bg-vblur.glsl ================================================ #version 300 es precision highp float; #define MAX_BLUR_RADIUS (200) in vec2 v_uv; uniform sampler2D u_prevPassTexture; uniform vec2 u_resolution; uniform int u_blurRadius; uniform float u_blurWeights[MAX_BLUR_RADIUS + 1]; out vec4 fragColor; void main() { vec2 texelSize = 1.0 / u_resolution; vec4 color = texture(u_prevPassTexture, v_uv) * u_blurWeights[0]; for (int i = 1; i <= u_blurRadius; ++i) { float w = u_blurWeights[i]; vec2 offset = vec2(float(i)) * texelSize; color += texture(u_prevPassTexture, v_uv + vec2(offset.x, 0.0)) * w; color += texture(u_prevPassTexture, v_uv - vec2(offset.x, 0.0)) * w; } fragColor = color; } ================================================ FILE: src/shaders/fragment-bg.glsl ================================================ #version 300 es precision highp float; in vec2 v_uv; out vec4 fragColor; uniform vec2 u_resolution; uniform float u_dpr; uniform vec2 u_mouse; uniform vec2 u_mouseSpring; uniform float u_time; uniform float u_mergeRate; uniform float u_shapeWidth; uniform float u_shapeHeight; uniform float u_shapeRadius; uniform float u_shapeRoundness; uniform float u_shadowExpand; uniform float u_shadowFactor; uniform vec2 u_shadowPosition; uniform int u_bgType; uniform sampler2D u_bgTexture; uniform float u_bgTextureRatio; uniform int u_bgTextureReady; uniform int u_showShape1; float chessboard(vec2 uv, float size, int mode) { float yBars = step(size * 2.0, mod(uv.y * 2.0, size * 4.0)); float xBars = step(size * 2.0, mod(uv.x * 2.0, size * 4.0)); if (mode == 0) { return yBars; } else if (mode == 1) { return xBars; } else { return abs(yBars - xBars); } } float halfColor(vec2 uv) { if (uv.y > 0.5) { return 1.0; } else { return 0.0; } } float sdCircle(vec2 p, float r) { return length(p) - r; } float superellipseCornerSDF(vec2 p, float r, float n) { p = abs(p); float v = pow(pow(p.x, n) + pow(p.y, n), 1.0 / n); return v - r; } float roundedRectSDF(vec2 p, vec2 center, float width, float height, float cornerRadius, float n) { // 移动到中心坐标系 p -= center; float cr = cornerRadius * u_dpr; // 计算到矩形边缘的距离 vec2 d = abs(p) - vec2(width * u_dpr, height * u_dpr) * 0.5; // 对于边缘区域和角落,我们需要不同的处理 float dist; if (d.x > -cr && d.y > -cr) { // 角落区域 vec2 cornerCenter = sign(p) * (vec2(width * u_dpr, height * u_dpr) * 0.5 - vec2(cr)); vec2 cornerP = p - cornerCenter; dist = superellipseCornerSDF(cornerP, cr, n); } else { // 内部和边缘区域 dist = min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); } return dist; } float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } float sdgMin(float a, float b) { return a < b ? a : b; } float mainSDF(vec2 p1, vec2 p2, vec2 p) { vec2 p1n = p1 + p / u_resolution.y; vec2 p2n = p2 + p / u_resolution.y; float d1 = u_showShape1 == 1 ? sdCircle(p1n, 100.0 * u_dpr / u_resolution.y) : 1.0; // float d2 = sdSuperellipse(p2, 200.0 / u_resolution.y, 4.0).x; float d2 = roundedRectSDF( p2n, vec2(0.0), u_shapeWidth / u_resolution.y, u_shapeHeight / u_resolution.y, u_shapeRadius / u_resolution.y, u_shapeRoundness ); return smin(d1, d2, u_mergeRate); } // 输入:原始 uv、canvas 宽高比、纹理宽高比 // 输出:变换后的 uv,可直接用于 texture 采样 vec2 getCoverUV(vec2 uv, float canvasAspect, float textureAspect) { if (canvasAspect > textureAspect) { // canvas 更宽,纹理竖向拉伸 float scale = textureAspect / canvasAspect; uv.y = uv.y * scale + 0.5 - 0.5 * scale; } else { // canvas 更高,纹理横向拉伸 float scale = canvasAspect / textureAspect; uv.x = uv.x * scale + 0.5 - 0.5 * scale; } return uv; } void main() { vec2 u_resolution1x = u_resolution.xy / u_dpr; // float chessboardBg = chessboard(gl_FragCoord.xy, 14.0); vec3 bgColor = vec3(1.0); if (u_bgType <= 0) { // chessboard bgColor = vec3(1.0 - chessboard(gl_FragCoord.xy / u_dpr, 20.0, 2) / 4.0); } else if (u_bgType <= 1) { if (v_uv.x < 0.5 && v_uv.y > 0.5) { bgColor = vec3(chessboard(gl_FragCoord.xy / u_dpr, 10.0, 0)); } else if (v_uv.x > 0.5 && v_uv.y < 0.5) { bgColor = vec3(chessboard(gl_FragCoord.xy / u_dpr, 10.0, 1)); } else if (v_uv.x < 0.5 && v_uv.y < 0.5) { bgColor = vec3(0.0); } } else if (u_bgType <= 2) { bgColor = vec3(halfColor(gl_FragCoord.xy / u_resolution) * 0.6 + 0.3); } else if (u_bgType <= 11) { if (u_bgTextureReady != 1) { // chessboard bgColor = vec3(1.0 - chessboard(gl_FragCoord.xy / u_dpr, 20.0, 2) / 4.0); } else { vec2 uv = getCoverUV(v_uv, u_resolution.x / u_resolution.y, u_bgTextureRatio); // 不需要判断越界,CLAMP_TO_EDGE 会自动处理 bgColor = texture(u_bgTexture, uv).rgb; } } // float chessboardBg = 1.0 - chessboard(gl_FragCoord.xy / u_dpr, 10.0) / 4.0; // float halfColorBg = halfColor(gl_FragCoord.xy / u_resolution); // draw shadow // center of shape 1 vec2 p1 = (vec2(0, 0) - u_resolution.xy * 0.5 + vec2(u_shadowPosition.x * u_dpr, u_shadowPosition.y * u_dpr)) / u_resolution.y; // center of shape 2 vec2 p2 = (vec2(0, 0) - u_mouseSpring + vec2(u_shadowPosition.x * u_dpr, u_shadowPosition.y * u_dpr)) / u_resolution.y; // merged shape float merged = mainSDF(p1, p2, gl_FragCoord.xy); float shadow = exp(-1.0 / u_shadowExpand * abs(merged) * u_resolution1x.y) * 0.6 * u_shadowFactor; fragColor = vec4(bgColor - vec3(shadow), 1.0); } ================================================ FILE: src/shaders/fragment-main-1.glsl ================================================ #version 300 es precision highp float; in vec4 v_color; out vec4 fragColor; uniform vec2 u_resolution; uniform vec2 u_mouse; uniform float u_time; #define MAX_BLUR_RADIUS (100) uniform int u_blurRadius; uniform float u_blurWeights[MAX_BLUR_RADIUS + 1]; float sdCircle(vec2 p, float r) { return length(p) - r; } float sdQuadraticCircle(vec2 p) { p = abs(p); if (p.y > p.x) p = p.yx; // symmetries float a = p.x - p.y; float b = p.x + p.y; float c = (2.0 * b - 1.0) / 3.0; float h = a * a + c * c * c; float t; if (h >= 0.0) { h = sqrt(h); t = sign(h - a) * pow(abs(h - a), 1.0 / 3.0) - pow(h + a, 1.0 / 3.0); } else { float z = sqrt(-c); float v = acos(a / (c * z)) / 3.0; t = -z * (cos(v) + sin(v) * 1.732050808); } t *= 0.5; vec2 w = vec2(-t, t) + 0.75 - t * t - p; return length(w) * sign(a * a * 0.5 + b - 1.5); } vec3 sdSuperellipse(vec2 p, float r, float n) { p = p / r; vec2 gs = sign(p); vec2 ps = abs(p); float gm = pow(ps.x, n) + pow(ps.y, n); float gd = pow(gm, 1.0 / n) - 1.0; vec2 g = gs * pow(ps, vec2(n - 1.0)) * pow(gm, 1.0 / n - 1.0); p = abs(p); if (p.y > p.x) p = p.yx; n = 2.0 / n; float s = 1.0; float d = 1e20; const int num = 24; vec2 oq = vec2(1.0, 0.0); for (int i = 1; i < num; i++) { float h = float(i) / float(num - 1); vec2 q = vec2(pow(cos(h * 3.1415927 / 4.0), n), pow(sin(h * 3.1415927 / 4.0), n)); vec2 pa = p - oq; vec2 ba = q - oq; vec2 z = pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); float d2 = dot(z, z); if (d2 < d) { d = d2; s = pa.x * ba.y - pa.y * ba.x; } oq = q; } return vec3(sqrt(d) * sign(s) * r, g); } // float squircle(in vec2 pos, in float rad4) { // vec2 tmp = pos * pos; // vec2 deriv = 4.0 * pos * tmp; // tmp = tmp * tmp; // float val4 = dot(vec2(1.0, 1.0), tmp); // float deriv_mag = length(deriv); // float sdf = (val4 - rad4) / deriv_mag; // // return clamp(0.5 * sdf * iResolution.y, 0.0, 1.0); // return sdf; // } float sdRoundBox(vec2 p, vec2 b, vec4 r) { r.xy = p.x > 0.0 ? r.xy : r.zw; r.x = p.y > 0.0 ? r.x : r.y; vec2 q = abs(p) - b + r.x; return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x; } float sdBox(vec2 p, vec2 b) { vec2 d = abs(p) - b; return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); } // vec3 sdgSMin( in vec3 a, in vec3 b, in float k ) // { // k *= 4.0; // float h = max( k-abs(a.x-b.x), 0.0 )/(2.0*k); // return vec3( min(a.x,b.x)-h*h*k, // mix(a.yz,b.yz,(a.x 1.0) ? x : // (x<-1.0) ? 0.0 : // (x+1.0)*(x+1.0)*(3.0-x*(x-2.0))/16.0; // return b - k * g; // } float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } // quadratic polynomial // float smin( float a, float b, float k ) // { // k *= 4.0; // float h = max( k-abs(a-b), 0.0 )/k; // return min(a,b) - h*h*k*(1.0/4.0); // } float chessboard(vec2 uv, float size) { float yBars = step(size * 2.0, mod(uv.y, size * 4.0)); float xBars = step(size * 2.0, mod(uv.x, size * 4.0)); return abs(yBars - xBars); } void main() { // vec2 p1 = (gl_FragCoord.xy - u_resolution.xy * 0.5) / u_resolution.y; // // float d1 = sdBox(p1, vec2(200.0, 30.0) / u_resolution.y); // float d1 = sdCircle(p1, 200.0 / u_resolution.y); // float a1 = smoothstep(0.0, 0.003, d1); // vec2 p2 = (gl_FragCoord.xy - u_mouse) / u_resolution.y; // float d2 = sdCircle(p2, 100.0 / u_resolution.y); // float d2 = sdQuadraticCircle(p2 / 0.2) * 0.2; // float d2 = sdSuperellipse(p2, 200.0 / u_resolution.y, 4.0).x; // float d2 = sdRoundBox(p2, vec2(300.0, 100.0) / u_resolution.y, vec4(100.0 / u_resolution.y)); // // float merged = smin(d1, d2, 0.05); // float px = 2.0/u_resolution.y; // vec3 col = (merged>0.0) ? vec3(0.9,0.6,0.3) : vec3(0.65,0.85,1.0); // // 阴影 // col *= 1.0 - exp(-0.03*abs(merged) * u_resolution.y); // // 等高线 // col *= 0.6 + 0.4*smoothstep(-0.5,0.5,cos(0.25 *abs(merged) * u_resolution.y)); // // 外层白框 // col = mix( col, vec3(1.0), 1.0-smoothstep(0.003-px,0.003+px,abs(merged))); // fragColor = vec4(col,1.0); // float smoothed = smoothstep(0.0, 0.0005, merged); // fragColor = vec4(vec3(smoothed), 1.0); vec2 p1 = (gl_FragCoord.xy - u_resolution.xy * 0.5) / u_resolution.y; vec2 p2 = (gl_FragCoord.xy - u_mouse) / u_resolution.y; float d1 = sdCircle(p1, 200.0 / u_resolution.y); float d2 = sdSuperellipse(p2, 200.0 / u_resolution.y, 4.0).x; float merged = smin(d1, d2, 0.05); // float smoothed = smoothstep(0.0, 0.0005, merged); vec4 BgColor; float chessboardBg = 1.0 - chessboard(gl_FragCoord.xy, 50.0) / 4.0; if (merged < 0.0) { // // blur background: // // gaussian blur // float Pi = 6.28318530718; // Pi*2 // // GAUSSIAN BLUR SETTINGS {{{ // float Directions = 264.0; // BLUR DIRECTIONS (Default 16.0 - More is better but slower) // float Quality = 22.0; // BLUR QUALITY (Default 4.0 - More is better but slower) // float Size = 100.0; // BLUR SIZE (Radius) // // }}} // vec2 Radius = Size / u_resolution.xy; // // chess board bg // BgColor = vec4(vec3(chessboard(bguv)), 1.0); // for (float d = 0.0; d < Pi; d += Pi / Directions) { // for (float i = 1.0 / Quality; i <= 1.0; i += 1.0 / Quality) { // BgColor += vec4(vec3(chessboard(bguv + vec2(cos(d), sin(d)) * Radius * i)), 1.0); // texture( iChannel0, uv+vec2(cos(d),sin(d))*Radius*i); // } // } // BgColor /= Quality * Directions - 15.0; // BgColor = vec4(vec3(1.0 - chessboardBg), 1.0); // blur float c = chessboard(gl_FragCoord.xy, 50.0) * u_blurWeights[0]; float weightSum = u_blurWeights[0]; for (int i = 1; i <= u_blurRadius; i++) { if (i > u_blurRadius) break; float w = u_blurWeights[i]; c += chessboard(gl_FragCoord.xy + vec2(float(i), 0.0), 50.0) * w; c += chessboard(gl_FragCoord.xy + vec2(-float(i), 0.0), 50.0) * w; c += chessboard(gl_FragCoord.xy + vec2(0.0, float(i)), 50.0) * w; c += chessboard(gl_FragCoord.xy + vec2(0.0, -float(i)), 50.0) * w; weightSum += w * 4.0; } c /= weightSum; BgColor = vec4(vec3(c), 1.0); } else { // BgColor = vec4(vec3(chessboard(bguv)), 1.0); BgColor = vec4(vec3(chessboardBg), 1.0); } // float smoothed = smoothstep(0.0, 0.0005, merged); // fragColor = vec4(mix(vec3(BgColor), vec3(smoothed), 0.0), 1.0); fragColor = BgColor; // float normalizedInside = merged / u_shapeHeight / u_resolution.y + 1.0; // float edgeBlendFactor = pow(normalizedInside, 12.0); // float smoothed = smoothstep(0.0, 0.0005, merged); // vec4 outColor; // if (merged < 0.0) { // outColor = texture(u_blurredBg, v_uv); // outColor = texture(u_blurredBg, v_uv); // float px = 2.0 / u_resolution.y; // vec3 col = merged > 0.0 ? vec3(0.9, 0.6, 0.3) : vec3(0.65, 0.85, 1.0); // // 阴影 // col *= 1.0 - exp(-0.03 * abs(merged) * u_resolution.y); // // 等高线 // col *= 0.6 + 0.4 * smoothstep(-0.5, 0.5, cos(0.25 * abs(merged) * u_resolution.y)); // // 外层白框 // col = mix(col, vec3(1.0), 1.0 - smoothstep(0.003 - px, 0.003 + px, abs(merged))); // float edgeEffect = pow(clamp(1.0 + merged * u_refEdge * u_resolution.y, 0.0, 1.0), 2.0); // float edgeEffect = 1.0; // if (edgeEffect > 0.0) { // vec2 normal = getNormal(p1, p2, gl_FragCoord.xy); // vec3 normalColor = vec3((normal * 0.5 + 0.5) * edgeEffect, 0.0); // vec3 normalColor = vec3(clamp(normal.x, 0.0, 1.0), abs(normal.y), clamp(-normal.x, 0.0, 1.0)); // vec3 normalColor = vec3() // // view edge // outColor = vec4(vec3(edgeEffect), 1.0); // // view normal // outColor = vec4(normalColor, 1.0); // outColor.r = texture(u_blurredBg, v_uv + normal * pow(edgeEffect * 0.5, 2.0) * 0.6).r; // outColor.g = texture(u_blurredBg, v_uv + normal * pow(edgeEffect * 0.5, 2.0) * 0.4).g; // outColor.b = texture(u_blurredBg, v_uv + normal * pow(edgeEffect * 0.5, 2.0) * 0.8).b; // final color // outColor = texture(u_blurredBg, v_uv - normal * pow(edgeEffect, 5.0) * 30.0 / 800.0); // outColor.r = texture( // u_blurredBg, // v_uv - normal / gl_FragCoord.y * pow(edgeEffect, 5.0) * 30.0 * 1.0 // ).r; // outColor.g = texture( // u_blurredBg, // v_uv - normal / gl_FragCoord.y * pow(edgeEffect, 5.0) * 30.0 * 1.0 // ).g; // outColor.b = texture( // u_blurredBg, // v_uv - normal / gl_FragCoord.y * pow(edgeEffect, 5.0) * 30.0 * 1.0 // ).b; // } else { // outColor = texture(u_blurredBg, v_uv); // } // outColor = mix(outColor, vec4(u_tint.r, u_tint.g, u_tint.b, u_tint.a * 0.5), u_tint.a * 0.5); // // outColor = vec4(mix(texture(u_blurredBg, v_uv).rgb, vec3(1.0), edgeEffect), 1.0); // } else { // outColor = texture(u_bg, v_uv); // } } ================================================ FILE: src/shaders/fragment-main.glsl ================================================ #version 300 es precision highp float; #define PI (3.14159265359) const float N_R = 1.0 - 0.02; const float N_G = 1.0; const float N_B = 1.0 + 0.02; in vec2 v_uv; uniform sampler2D u_blurredBg; uniform sampler2D u_bg; uniform vec2 u_resolution; uniform float u_dpr; uniform vec2 u_mouse; uniform vec2 u_mouseSpring; uniform float u_mergeRate; uniform float u_shapeWidth; uniform float u_shapeHeight; uniform float u_shapeRadius; uniform float u_shapeRoundness; uniform vec4 u_tint; uniform float u_refThickness; uniform float u_refFactor; uniform float u_refDispersion; uniform float u_refFresnelRange; uniform float u_refFresnelFactor; uniform float u_refFresnelHardness; uniform float u_glareRange; uniform float u_glareConvergence; uniform float u_glareOppositeFactor; uniform float u_glareFactor; uniform float u_glareHardness; uniform float u_glareAngle; uniform int u_blurEdge; uniform int u_showShape1; uniform int STEP; out vec4 fragColor; float sdCircle(vec2 p, float r) { return length(p) - r; } vec3 sdSuperellipse(vec2 p, float r, float n) { p = p / r; vec2 gs = sign(p); vec2 ps = abs(p); float gm = pow(ps.x, n) + pow(ps.y, n); float gd = pow(gm, 1.0 / n) - 1.0; vec2 g = gs * pow(ps, vec2(n - 1.0)) * pow(gm, 1.0 / n - 1.0); p = abs(p); if (p.y > p.x) p = p.yx; n = 2.0 / n; float s = 1.0; float d = 1e20; const int num = 24; vec2 oq = vec2(1.0, 0.0); for (int i = 1; i < num; i++) { float h = float(i) / float(num - 1); vec2 q = vec2(pow(cos(h * PI / 4.0), n), pow(sin(h * PI / 4.0), n)); vec2 pa = p - oq; vec2 ba = q - oq; vec2 z = pa - ba * clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); float d2 = dot(z, z); if (d2 < d) { d = d2; s = pa.x * ba.y - pa.y * ba.x; } oq = q; } return vec3(sqrt(d) * sign(s) * r, g); } float superellipseCornerSDF(vec2 p, float r, float n) { p = abs(p); float v = pow(pow(p.x, n) + pow(p.y, n), 1.0 / n); return v - r; } float roundedRectSDF(vec2 p, vec2 center, float width, float height, float cornerRadius, float n) { // 移动到中心坐标系 p -= center; float cr = cornerRadius * u_dpr; // 计算到矩形边缘的距离 vec2 d = abs(p) - vec2(width * u_dpr, height * u_dpr) * 0.5; // 对于边缘区域和角落,我们需要不同的处理 float dist; if (d.x > -cr && d.y > -cr) { // 角落区域 vec2 cornerCenter = sign(p) * (vec2(width * u_dpr, height * u_dpr) * 0.5 - vec2(cr)); vec2 cornerP = p - cornerCenter; dist = superellipseCornerSDF(cornerP, cr, n); } else { // 内部和边缘区域 dist = min(max(d.x, d.y), 0.0) + length(max(d, 0.0)); } return dist; } float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } float mainSDF(vec2 p1, vec2 p2, vec2 p) { vec2 p1n = p1 + p / u_resolution.y; vec2 p2n = p2 + p / u_resolution.y; float d1 = u_showShape1 == 1 ? sdCircle(p1n, 100.0 * u_dpr / u_resolution.y) : 1.0; // float d2 = sdSuperellipse(p2, 200.0 / u_resolution.y, 4.0).x; float d2 = roundedRectSDF( p2n, vec2(0.0), u_shapeWidth / u_resolution.y, u_shapeHeight / u_resolution.y, u_shapeRadius / u_resolution.y, u_shapeRoundness ); return smin(d1, d2, u_mergeRate); } vec2 getNormal(vec2 p1, vec2 p2, vec2 p) { // 使用场景尺度自适应的 eps vec2 h = vec2(max(abs(dFdx(p.x)), 0.0001), max(abs(dFdy(p.y)), 0.0001)); vec2 grad = vec2( mainSDF(p1, p2, p + vec2(h.x, 0.0)) - mainSDF(p1, p2, p - vec2(h.x, 0.0)), mainSDF(p1, p2, p + vec2(0.0, h.y)) - mainSDF(p1, p2, p - vec2(0.0, h.y)) ) / (2.0 * h); // return normalize(grad); return grad * 1.414213562 * 1000.0; } vec2 getNormal2(vec2 p1, vec2 p2, vec2 p) { float eps = 0.7071 * 0.0005; // ~1/sqrt(2) * epsilon vec2 e1 = vec2(1.0, 1.0); vec2 e2 = vec2(-1.0, 1.0); vec2 e3 = vec2(1.0, -1.0); vec2 e4 = vec2(-1.0, -1.0); return normalize( e1 * mainSDF(p1, p2, p + eps * e1) + e2 * mainSDF(p1, p2, p + eps * e2) + e3 * mainSDF(p1, p2, p + eps * e3) + e4 * mainSDF(p1, p2, p + eps * e4) ); } vec2 getNormal3(vec2 p1, vec2 p2, vec2 p) { float eps = 0.0005; vec2 e = vec2(eps, 0.0); float dx = mainSDF(p1, p2, p + e.xy) - mainSDF(p1, p2, p - e.xy); // ∂f/∂x float dy = mainSDF(p1, p2, p + e.yx) - mainSDF(p1, p2, p - e.yx); // ∂f/∂y return normalize(vec2(dx, dy)); } vec3 hsv2rgb(vec3 c) { vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); } // from https://github.com/Rachmanin0xFF/GLSL-Color-Functions/blob/main/color-functions.glsl // 0.3127/0.3290 1.0 (1.0-0.3127-0.3290)/0.329 const vec3 D65_WHITE = vec3(0.95045592705, 1.0, 1.08905775076); // 0.3457/0.3585 1.0 (1.0-0.3457-0.3585)/0.3585 const vec3 D50_WHITE = vec3(0.96429567643, 1.0, 0.82510460251); vec3 WHITE = D65_WHITE; const mat3 RGB_TO_XYZ_M = mat3( 0.4124, 0.3576, 0.1805, 0.2126, 0.7152, 0.0722, 0.0193, 0.1192, 0.9505 ); const mat3 XYZ_TO_XYZ50_M = mat3( 1.0479298208405488 , 0.022946793341019088, -0.05019222954313557 , 0.029627815688159344, 0.990434484573249 , -0.01707382502938514 , -0.009243058152591178, 0.015055144896577895, 0.7518742899580008 ); const mat3 XYZ_TO_RGB_M = mat3( 3.2406255, -1.537208 , -0.4986286, -0.9689307, 1.8757561, 0.0415175, 0.0557101, -0.2040211, 1.0569959 ); const mat3 XYZ50_TO_XYZ_M = mat3( 0.9554734527042182 , -0.023098536874261423, 0.0632593086610217 , -0.028369706963208136, 1.0099954580058226 , 0.021041398966943008, 0.012314001688319899, -0.020507696433477912, 1.3303659366080753 ); float UNCOMPAND_SRGB(float a) { return a > 0.04045 ? pow((a + 0.055) / 1.055, 2.4) : a / 12.92; } float COMPAND_RGB(float a) { return a <= 0.0031308 ? 12.92 * a : 1.055 * pow(a, 0.41666666666) - 0.055; } vec3 RGB_TO_XYZ(vec3 rgb) { return WHITE == D65_WHITE ? rgb * RGB_TO_XYZ_M : rgb * RGB_TO_XYZ_M * XYZ_TO_XYZ50_M; } vec3 SRGB_TO_RGB(vec3 srgb) { return vec3(UNCOMPAND_SRGB(srgb.x), UNCOMPAND_SRGB(srgb.y), UNCOMPAND_SRGB(srgb.z)); } vec3 RGB_TO_SRGB(vec3 rgb) { return vec3(COMPAND_RGB(rgb.x), COMPAND_RGB(rgb.y), COMPAND_RGB(rgb.z)); } vec3 SRGB_TO_XYZ(vec3 srgb) { return RGB_TO_XYZ(SRGB_TO_RGB(srgb)); } float XYZ_TO_LAB_F(float x) { // (24/116)^3 1/(3*(6/29)^2) 4/29 return x > 0.00885645167 ? pow(x, 0.333333333) : 7.78703703704 * x + 0.13793103448; } vec3 XYZ_TO_LAB(vec3 xyz) { vec3 xyz_scaled = xyz / WHITE; xyz_scaled = vec3( XYZ_TO_LAB_F(xyz_scaled.x), XYZ_TO_LAB_F(xyz_scaled.y), XYZ_TO_LAB_F(xyz_scaled.z) ); return vec3( 116.0 * xyz_scaled.y - 16.0, 500.0 * (xyz_scaled.x - xyz_scaled.y), 200.0 * (xyz_scaled.y - xyz_scaled.z) ); } vec3 SRGB_TO_LAB(vec3 srgb) { return XYZ_TO_LAB(SRGB_TO_XYZ(srgb)); } vec3 LAB_TO_LCH(vec3 Lab) { return vec3(Lab.x, sqrt(dot(Lab.yz, Lab.yz)), atan(Lab.z, Lab.y) * 57.2957795131); } vec3 SRGB_TO_LCH(vec3 srgb) { return LAB_TO_LCH(SRGB_TO_LAB(srgb)); } vec3 XYZ_TO_RGB(vec3 xyz) { return WHITE == D65_WHITE ? xyz * XYZ_TO_RGB_M : xyz * XYZ50_TO_XYZ_M * XYZ_TO_RGB_M; } vec3 XYZ_TO_SRGB(vec3 xyz) { return RGB_TO_SRGB(XYZ_TO_RGB(xyz)); } float LAB_TO_XYZ_F(float x) { // 3*(6/29)^2 4/29 return x > 0.206897 ? x * x * x : 0.12841854934 * (x - 0.137931034); } vec3 LAB_TO_XYZ(vec3 Lab) { float w = (Lab.x + 16.0) / 116.0; return WHITE * vec3(LAB_TO_XYZ_F(w + Lab.y / 500.0), LAB_TO_XYZ_F(w), LAB_TO_XYZ_F(w - Lab.z / 200.0)); } vec3 LAB_TO_SRGB(vec3 lab) { return XYZ_TO_SRGB(LAB_TO_XYZ(lab)); } vec3 LCH_TO_LAB(vec3 LCh) { return vec3(LCh.x, LCh.y * cos(LCh.z * 0.01745329251), LCh.y * sin(LCh.z * 0.01745329251)); } vec3 LCH_TO_SRGB(vec3 lch) { return LAB_TO_SRGB(LCH_TO_LAB(lch)); } float vec2ToAngle(vec2 v) { float angle = atan(v.y, v.x); if (angle < 0.0) angle += 2.0 * PI; return angle; } vec3 vec2ToRgb(vec2 v) { float angle = atan(v.y, v.x); if (angle < 0.0) angle += 2.0 * PI; float hue = angle / (2.0 * PI); vec3 hsv = vec3(hue, 1.0, 1.0); return hsv2rgb(hsv); } vec4 getTextureDispersion( sampler2D tex1, sampler2D tex2, float mixRate, vec2 offset, float factor ) { vec4 pixel = vec4(1.0); float bgR = texture(tex1, v_uv + offset * (1.0 - (N_R - 1.0) * factor)).r; float bgG = texture(tex1, v_uv + offset * (1.0 - (N_G - 1.0) * factor)).g; float bgB = texture(tex1, v_uv + offset * (1.0 - (N_B - 1.0) * factor)).b; float blurR = texture(tex2, v_uv + offset * (1.0 - (N_R - 1.0) * factor)).r; float blurG = texture(tex2, v_uv + offset * (1.0 - (N_G - 1.0) * factor)).g; float blurB = texture(tex2, v_uv + offset * (1.0 - (N_B - 1.0) * factor)).b; pixel.r = mix(bgR, blurR, mixRate); pixel.g = mix(bgG, blurG, mixRate); pixel.b = mix(bgB, blurB, mixRate); return pixel; } void main() { vec2 u_resolution1x = u_resolution.xy / u_dpr; // center of shape 1 vec2 p1 = (vec2(0, 0) - u_resolution.xy * 0.5) / u_resolution.y; // center of shape 2 vec2 p2 = (vec2(0, 0) - u_mouseSpring) / u_resolution.y; // merged shape float merged = mainSDF(p1, p2, gl_FragCoord.xy); vec4 outColor; // step 0: sdfs if (STEP <= 0) { float px = 2.0 / u_resolution.y; vec3 col = merged > 0.0 ? vec3(1.0, 1.0, 1.0) * merged : vec3(1.0, 1.0, 1.0) * -merged * 2.0; col *= 3.0; col = mix( col, vec3(1.0), 1.0 - smoothstep(0.5 / u_resolution1x.y - px, 0.5 / u_resolution1x.y + px, abs(merged)) ); outColor = vec4(col, 1.0); } else if (STEP <= 1) { float px = 2.0 / u_resolution.y; vec3 col = merged > 0.0 ? vec3(0.9, 0.6, 0.3) : vec3(0.65, 0.85, 1.0); // 阴影 col *= 1.0 - exp(-0.03 * abs(merged) * u_resolution1x.y); // 等高线 col *= 0.6 + 0.4 * smoothstep(-0.5, 0.5, cos(0.25 * abs(merged) * u_resolution1x.y * 2.0)); // 外层白框 col = mix( col, vec3(1.0), 1.0 - smoothstep(1.5 / u_resolution1x.y - px, 1.5 / u_resolution1x.y + px, abs(merged)) ); outColor = vec4(col, 1.0); // step 1: normals } else if (STEP <= 2) { if (merged < 0.0) { vec2 normal = getNormal(p1, p2, gl_FragCoord.xy); vec3 normalColor = vec2ToRgb(normal); float l = length(normal); outColor = vec4(normalColor, l); } else { outColor = vec4(vec3(0.8), 0.0); } // step2: edge factors } else if (STEP <= 3) { if (merged < 0.0) { float nmerged = -1.0 * (merged * u_resolution1x.y); float x_R_ratio = 1.0 - nmerged / u_refThickness; float thetaI = asin(pow(x_R_ratio, 2.0)); float thetaT = asin(1.0 / u_refFactor * sin(thetaI)); float edgeFactor = -1.0 * tan(thetaT - thetaI); if (nmerged >= u_refThickness) { edgeFactor = 0.0; } if (nmerged < u_refThickness) { outColor = vec4(vec3(edgeFactor), 1.0); } else { outColor = vec4(vec3(0.0), 1.0); } } else { outColor = vec4(0.0); } // step3: edge factor with normal } else if (STEP <= 4) { if (merged < 0.0) { vec2 normal = getNormal(p1, p2, gl_FragCoord.xy); vec3 normalColor = vec2ToRgb(normal); float nmerged = -1.0 * (merged * u_resolution1x.y); float x_R_ratio = 1.0 - nmerged / u_refThickness; float thetaI = asin(pow(x_R_ratio, 2.0)); float thetaT = asin(1.0 / u_refFactor * sin(thetaI)); float edgeFactor = -1.0 * tan(thetaT - thetaI); if (nmerged >= u_refThickness) { edgeFactor = 0.0; } outColor = vec4(normalColor * edgeFactor * u_dpr * length(normal), 1.0); } else { outColor = vec4(0.0); } // add refaction } else if (STEP <= 5) { if (merged < 0.0) { outColor = texture(u_blurredBg, v_uv); } else { outColor = texture(u_bg, v_uv); } } else if (STEP <= 6) { if (merged < 0.0) { vec2 normal = getNormal(p1, p2, gl_FragCoord.xy); float nmerged = -1.0 * (merged * u_resolution1x.y); float x_R_ratio = 1.0 - nmerged / u_refThickness; float thetaI = asin(pow(x_R_ratio, 2.0)); float thetaT = asin(1.0 / u_refFactor * sin(thetaI)); float edgeFactor = -1.0 * tan(thetaT - thetaI); // Will have value > 0 inside of shape, force normalize here if (nmerged >= u_refThickness) { edgeFactor = 0.0; } if (edgeFactor <= 0.0) { outColor = texture(u_blurredBg, v_uv); } else { vec4 blurredPixel = texture( u_blurredBg, v_uv - normal * edgeFactor * 0.05 * u_dpr * vec2( u_resolution.y / u_resolution1x.x, /* resolution independent */ 1.0 ) ); outColor = blurredPixel; } } else { outColor = texture(u_bg, v_uv); } // } else if (STEP <= 7) { if (merged < 0.0) { vec2 normal = getNormal(p1, p2, gl_FragCoord.xy); float nmerged = -1.0 * (merged * u_resolution1x.y); float x_R_ratio = 1.0 - nmerged / u_refThickness; float thetaI = asin(pow(x_R_ratio, 2.0)); float thetaT = asin(1.0 / u_refFactor * sin(thetaI)); float edgeFactor = -1.0 * tan(thetaT - thetaI); // Will have value > 0 inside of shape, force normalize here if (nmerged >= u_refThickness) { edgeFactor = 0.0; } // other fresnel implements: // float r0 = pow((1.0 - u_refFactor) / (1.0 + u_refFactor), 2.0); // float fresnelFactor = r0 + (1.0 - r0) * pow(1.0 - cos(thetaI), 5.0); // if (fresnelFactor < 0.028) { // fresnelFactor = 0.0; // } // fresnelFactor *= 10.0; // float fresnelFactor = // 0.5 * // (pow(sin(thetaI - thetaT) / sin(thetaI + thetaT), 2.0) + // pow(tan(thetaI - thetaT) / tan(thetaI + thetaT), 2.0)); // fresnelFactor = clamp(fresnelFactor, 0.0, 1.0); float fresnelFactor = clamp( pow( 1.0 + merged * u_resolution1x.y / 1500.0 * pow(500.0 / u_refFresnelRange, 2.0) + u_refFresnelHardness, 5.0 ), 0.0, 1.0 ); if (edgeFactor <= 0.0) { outColor = texture(u_blurredBg, v_uv); } else { vec4 blurredPixel = texture( u_blurredBg, v_uv - normal * edgeFactor * 0.05 * u_dpr * vec2( u_resolution.y / u_resolution1x.x, /* resolution independent */ 1.0 ), u_refDispersion ); outColor = mix(blurredPixel, vec4(1.0), fresnelFactor * u_refFresnelFactor * 0.7); // outColor = vec4(vec3(fresnelFactor), 1.0); } } else { outColor = texture(u_bg, v_uv); } } else if (STEP <= 8) { if (merged < 0.0) { float nmerged = -1.0 * (merged * u_resolution1x.y); float x_R_ratio = 1.0 - nmerged / u_refThickness; float thetaI = asin(pow(x_R_ratio, 2.0)); float thetaT = asin(1.0 / u_refFactor * sin(thetaI)); float edgeFactor = -1.0 * tan(thetaT - thetaI); // Will have value > 0 inside of shape, force normalize here if (nmerged >= u_refThickness) { edgeFactor = 0.0; } float fresnelFactor = clamp( pow( 1.0 + merged * u_resolution1x.y / 1500.0 * pow(500.0 / u_refFresnelRange, 2.0) + u_refFresnelHardness, 5.0 ), 0.0, 1.0 ); float glareGeoFactor = clamp( pow( 1.0 + merged * u_resolution1x.y / 1500.0 * pow(500.0 / u_glareRange, 2.0) + u_glareHardness, 5.0 ), 0.0, 1.0 ); if (edgeFactor <= 0.0) { outColor = texture(u_blurredBg, v_uv); // // outColor = mix( // outColor, // vec4(u_tint.r, u_tint.g, u_tint.b, u_tint.a * 0.5), // u_tint.a * 0.8 // ); // outColor.a = 1.0; } else { vec2 normal = getNormal(p1, p2, gl_FragCoord.xy); float glareAngle = (vec2ToAngle(normalize(normal)) - PI / 4.0 + u_glareAngle) * 2.0; int glareFarside = 0; if ( glareAngle > PI * (2.0 - 0.5) && glareAngle < PI * (4.0 - 0.5) || glareAngle < PI * (0.0 - 0.5) ) { glareFarside = 1; } float glareAngleFactor = (0.5 + sin(glareAngle) * 0.5) * 1.0 * (glareFarside == 1 ? 0.8 : 1.2) * u_glareFactor; glareAngleFactor = clamp(pow(glareAngleFactor, 0.3 + u_glareConvergence * 1.5), 0.0, 1.0); vec4 blurredPixel = texture( u_blurredBg, v_uv - normal * edgeFactor * 0.05 * u_dpr * vec2( u_resolution.y / u_resolution1x.x, /* resolution independent */ 1.0 ), u_refDispersion ); // // outColor = mix( // blurredPixel, // vec4(u_tint.r, u_tint.g, u_tint.b, u_tint.a * 0.5), // u_tint.a * 0.8 // ); // outColor.a = 1.0; // outColor = mix(outColor, vec4(1.0), fresnelFactor * u_refFresnelFactor * 0.7); outColor = blurredPixel; vec3 tintLCH = SRGB_TO_LCH( mix(vec3(1.0), vec3(u_tint.r, u_tint.g, u_tint.b), u_tint.a * 0.5) ); tintLCH.x += 20.0 * fresnelFactor * u_refFresnelFactor; tintLCH.x = clamp(tintLCH.x, 0.0, 100.0); outColor = mix( outColor, // vec4( // LCH_TO_SRGB(tintLCH), // u_tint.a * 0.5 // ), vec4(1.0), fresnelFactor * u_refFresnelFactor * 0.7 ); // ------ outColor = mix( outColor, // vec4( // LCH_TO_SRGB(tintLCH), // u_tint.a * 0.5 // ), vec4(1.0), glareAngleFactor * glareGeoFactor ); // outColor = vec4(vec3(glareAngleFactor * glareGeoFactor), 1.0); } } else { outColor = texture(u_bg, v_uv); } } else if (STEP <= 9) { if (merged < 0.005) { float nmerged = -1.0 * (merged * u_resolution1x.y); // calculate refraction edge factor: float x_R_ratio = 1.0 - nmerged / u_refThickness; float thetaI = asin(pow(x_R_ratio, 2.0)); float thetaT = asin(1.0 / u_refFactor * sin(thetaI)); float edgeFactor = -1.0 * tan(thetaT - thetaI); // Will have value > 0 inside of shape, force normalize here if (nmerged >= u_refThickness) { edgeFactor = 0.0; } if (edgeFactor <= 0.0) { outColor = texture(u_blurredBg, v_uv); outColor = mix(outColor, vec4(u_tint.r, u_tint.g, u_tint.b, 1.0), u_tint.a * 0.8); } else { // height of glass edge: // h = r - sqrt(r*r - x*x) // (0<=x<=r) float edgeH = nmerged / u_refThickness; // (u_refThickness - sqrt(u_refThickness * u_refThickness - nmerged * nmerged)) / // u_refThickness; // u_refThickness - pow(u_refThickness * u_refThickness - nmerged * nmerged, 0.5); // u_refThickness - pow(u_refThickness * u_refThickness - nmerged * nmerged, 0.5); // calculate parameters vec2 normal = getNormal(p1, p2, gl_FragCoord.xy); vec4 blurredPixel = getTextureDispersion( u_bg, u_blurredBg, u_blurEdge > 0 ? 1.0 : edgeH, -normal * edgeFactor * 0.05 * u_dpr * vec2( u_resolution.y / (u_resolution1x.x * u_dpr), /* resolution independent */ 1.0 ), u_refDispersion ); // basic tint outColor = mix(blurredPixel, vec4(u_tint.r, u_tint.g, u_tint.b, 1.0), u_tint.a * 0.8); // add fresnel float fresnelFactor = clamp( pow( 1.0 + merged * u_resolution1x.y / 1500.0 * pow(500.0 / u_refFresnelRange, 2.0) + u_refFresnelHardness, 5.0 ), 0.0, 1.0 ); vec3 fresnelTintLCH = SRGB_TO_LCH( mix(vec3(1.0), vec3(u_tint.r, u_tint.g, u_tint.b), u_tint.a * 0.5) ); fresnelTintLCH.x += 20.0 * fresnelFactor * u_refFresnelFactor; fresnelTintLCH.x = clamp(fresnelTintLCH.x, 0.0, 100.0); outColor = mix( outColor, vec4(LCH_TO_SRGB(fresnelTintLCH), 1.0), fresnelFactor * u_refFresnelFactor * 0.7 * length(normal) ); // add glare float glareGeoFactor = clamp( pow( 1.0 + merged * u_resolution1x.y / 1500.0 * pow(500.0 / u_glareRange, 2.0) + u_glareHardness, 5.0 ), 0.0, 1.0 ); float glareAngle = (vec2ToAngle(normalize(normal)) - PI / 4.0 + u_glareAngle) * 2.0; int glareFarside = 0; if ( glareAngle > PI * (2.0 - 0.5) && glareAngle < PI * (4.0 - 0.5) || glareAngle < PI * (0.0 - 0.5) ) { glareFarside = 1; } float glareAngleFactor = (0.5 + sin(glareAngle) * 0.5) * (glareFarside == 1 ? 1.2 * u_glareOppositeFactor : 1.2) * u_glareFactor; glareAngleFactor = clamp(pow(glareAngleFactor, 0.1 + u_glareConvergence * 2.0), 0.0, 1.0); vec3 glareTintLCH = SRGB_TO_LCH( mix(blurredPixel.rgb, vec3(u_tint.r, u_tint.g, u_tint.b), u_tint.a * 0.5) ); glareTintLCH.x += 150.0 * glareAngleFactor * glareGeoFactor; glareTintLCH.y += 30.0 * glareAngleFactor * glareGeoFactor; glareTintLCH.x = clamp(glareTintLCH.x, 0.0, 120.0); outColor = mix( outColor, vec4(LCH_TO_SRGB(glareTintLCH), 1.0), glareAngleFactor * glareGeoFactor * length(normal) ); } } else { outColor = texture(u_bg, v_uv); } // smooth outColor = mix(outColor, texture(u_bg, v_uv), smoothstep(-0.001, 0.001, merged)); } fragColor = outColor; } ================================================ FILE: src/shaders/test.glsl ================================================ #version 300 es precision mediump float; in vec3 vVertexPosition; in vec2 vTextureCoord; uniform sampler2D uTexture; uniform sampler2D uCustomTexture; uniform float uScale; uniform float uAngle; uniform float uOpacity; uniform float uMix; uniform int uBlendMode; uniform float uDispersion; uniform int uShowBg; uniform int uShape; uniform int uInvert; uniform int uMouseDistortDir; uniform float uRefraction; uniform float uMouseDistort; uniform vec3 uColor; uniform vec2 uPos; uniform sampler2D uMaskTexture; uniform int uIsMask; uniform float uTrackMouse; uniform vec2 uMousePos; uniform vec2 uResolution; uniform float uParentTrackMouse; vec3 blend(int blendMode, vec3 src, vec3 dst) { if (blendMode == 0) { return src; } if (blendMode == 1) { return src + dst; } if (blendMode == 2) { return src - dst; } if (blendMode == 3) { return src * dst; } if (blendMode == 4) { return 1.0 - (1.0 - src) * (1.0 - dst); } if (blendMode == 5) { return vec3( dst.x <= 0.5 ? 2.0 * src.x * dst.x : 1.0 - 2.0 * (1.0 - dst.x) * (1.0 - src.x), dst.y <= 0.5 ? 2.0 * src.y * dst.y : 1.0 - 2.0 * (1.0 - dst.y) * (1.0 - src.y), dst.z <= 0.5 ? 2.0 * src.z * dst.z : 1.0 - 2.0 * (1.0 - dst.z) * (1.0 - src.z) ); } if (blendMode == 6) { return min(src, dst); } if (blendMode == 7) { return max(src, dst); } if (blendMode == 8) { return vec3( src.x == 1.0 ? 1.0 : min(1.0, dst.x / (1.0 - src.x)), src.y == 1.0 ? 1.0 : min(1.0, dst.y / (1.0 - src.y)), src.z == 1.0 ? 1.0 : min(1.0, dst.z / (1.0 - src.z)) ); } if (blendMode == 9) { return vec3( src.x == 0.0 ? 0.0 : 1.0 - (1.0 - dst.x) / src.x, src.y == 0.0 ? 0.0 : 1.0 - (1.0 - dst.y) / src.y, src.z == 0.0 ? 0.0 : 1.0 - (1.0 - dst.z) / src.z ); } if (blendMode == 10) { return src + dst - 1.0; } if (blendMode == 11) { return vec3( src.x <= 0.5 ? 2.0 * src.x * dst.x : 1.0 - 2.0 * (1.0 - src.x) * (1.0 - dst.x), src.y <= 0.5 ? 2.0 * src.y * dst.y : 1.0 - 2.0 * (1.0 - src.y) * (1.0 - dst.y), src.z <= 0.5 ? 2.0 * src.z * dst.z : 1.0 - 2.0 * (1.0 - src.z) * (1.0 - dst.z) ); } if (blendMode == 12) { return vec3( src.x <= 0.5 ? dst.x - (1.0 - 2.0 * src.x) * dst.x * (1.0 - dst.x) : src.x > 0.5 && dst.x <= 0.25 ? dst.x + (2.0 * src.x - 1.0) * (4.0 * dst.x * (4.0 * dst.x + 1.0) * (dst.x - 1.0) + 7.0 * dst.x) : dst.x + (2.0 * src.x - 1.0) * (sqrt(dst.x) - dst.x), src.y <= 0.5 ? dst.y - (1.0 - 2.0 * src.y) * dst.y * (1.0 - dst.y) : src.y > 0.5 && dst.y <= 0.25 ? dst.y + (2.0 * src.y - 1.0) * (4.0 * dst.y * (4.0 * dst.y + 1.0) * (dst.y - 1.0) + 7.0 * dst.y) : dst.y + (2.0 * src.y - 1.0) * (sqrt(dst.y) - dst.y), src.z <= 0.5 ? dst.z - (1.0 - 2.0 * src.z) * dst.z * (1.0 - dst.z) : src.z > 0.5 && dst.z <= 0.25 ? dst.z + (2.0 * src.z - 1.0) * (4.0 * dst.z * (4.0 * dst.z + 1.0) * (dst.z - 1.0) + 7.0 * dst.z) : dst.z + (2.0 * src.z - 1.0) * (sqrt(dst.z) - dst.z) ); } if (blendMode == 13) { return abs(dst - src); } if (blendMode == 14) { return src + dst - 2.0 * src * dst; } if (blendMode == 15) { return 2.0 * src + dst - 1.0; } if (blendMode == 16) { return vec3( src.x > 0.5 ? max(dst.x, 2.0 * (src.x - 0.5)) : min(dst.x, 2.0 * src.x), src.x > 0.5 ? max(dst.y, 2.0 * (src.y - 0.5)) : min(dst.y, 2.0 * src.y), src.z > 0.5 ? max(dst.z, 2.0 * (src.z - 0.5)) : min(dst.z, 2.0 * src.z) ); } if (blendMode == 17) { return vec3( src.x <= 0.5 ? 1.0 - (1.0 - dst.x) / (2.0 * src.x) : dst.x / (2.0 * (1.0 - src.x)), src.y <= 0.5 ? 1.0 - (1.0 - dst.y) / (2.0 * src.y) : dst.y / (2.0 * (1.0 - src.y)), src.z <= 0.5 ? 1.0 - (1.0 - dst.z) / (2.0 * src.z) : dst.z / (2.0 * (1.0 - src.z)) ); } } out vec4 fragColor; const float PI = 3.14159265359; mat2 rot(float a) { return mat2(cos(a), -sin(a), sin(a), cos(a)); } float sdCircle(vec2 uv, float r) { return length(uv) - r; } float sdSquare(vec2 uv, float r) { return max(abs(uv.x), abs(uv.y)) - r; } float sdEquilateralTriangle(vec2 p, float r) { const float k = sqrt(3.0); p.x = abs(p.x) - r; p.y = p.y + r / k; if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0; p.x -= clamp(p.x, -2.0 * r, 0.0); return -length(p) * sign(p.y); } float sdStar5(vec2 p, float r, float rf) { const vec2 k1 = vec2(0.809016994375, -0.587785252292); const vec2 k2 = vec2(-k1.x, k1.y); p.x = abs(p.x); p -= 2.0 * max(dot(k1, p), 0.0) * k1; p -= 2.0 * max(dot(k2, p), 0.0) * k2; p.x = abs(p.x); p.y -= r; vec2 ba = rf * vec2(-k1.y, k1.x) - vec2(0, 1); float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r); return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y); } float sdDiamond(vec2 p, float r) { p = abs(p); return (p.x + p.y - r) * 0.70710678118; } float sdPentagon(vec2 p, float r) { const vec3 k = vec3(0.809016994, 0.587785252, 0.726542528); p.x = abs(p.x); p -= 2.0 * min(dot(vec2(-k.x, k.y), p), 0.0) * vec2(-k.x, k.y); p -= 2.0 * min(dot(vec2(k.x, k.y), p), 0.0) * vec2(k.x, k.y); p -= vec2(clamp(p.x, -r * k.z, r * k.z), r); return length(p) * sign(p.y); } float sdHexagon(vec2 p, float r) { const vec3 k = vec3(-0.866025404, 0.5, 0.577350269); p = abs(p); p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy; p -= vec2(clamp(p.x, -k.z * r, k.z * r), r); return length(p) * sign(p.y); } float sdOctagon(vec2 p, float r) { const vec3 k = vec3(-0.9238795325, 0.3826834323, 0.4142135623); p = abs(p); p -= 2.0 * min(dot(vec2(k.x, k.y), p), 0.0) * vec2(k.x, k.y); p -= 2.0 * min(dot(vec2(-k.x, k.y), p), 0.0) * vec2(-k.x, k.y); p -= vec2(clamp(p.x, -r * k.z, r * k.z), r); return length(p) * sign(p.y); } float sdVesica(vec2 p, float r) { float d = r; vec2 c1 = vec2(-d * 0.5, 0.0); vec2 c2 = vec2(d * 0.5, 0.0); return max(length(p - c1) - r, length(p - c2) - r); } float sdBox(vec2 p, vec2 b) { vec2 q = abs(p) - b; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0); } float sdArchway(vec2 p, float r_param) { p.y += 0.15; vec2 p_rect_center = vec2(0.0, 0); vec2 rect_half_extents = vec2(r_param, r_param); float d_rect = sdBox(p - p_rect_center, rect_half_extents); vec2 circle_center = vec2(0.0, r_param); float d_disk = length(p - circle_center) - r_param; float d_plane_above_rect_top = r_param - p.y; float d_semicircle = max(d_disk, d_plane_above_rect_top); return min(d_rect, d_disk); } float median(float r, float g, float b) { return max(min(r, g), min(max(r, g), b)); } float screenPxRange(vec2 range) { vec2 unitRange = range / vec2(textureSize(uCustomTexture, 0)); vec2 screenTexSize = vec2(1.0) / fwidth(vTextureCoord); return max(0.5 * dot(unitRange, screenTexSize), 1.0); } float sdCustom(vec2 uv) { ivec2 customTexSize = textureSize(uCustomTexture, 0); float customTexAspect = float(customTexSize.x) / float(customTexSize.y); if ( float(customTexSize.x) == float(uResolution.x) && float(customTexSize.y) == float(uResolution.y) ) { return 1.0; } uv.x /= customTexAspect; uv += 0.5; vec4 sdColor = texture(uCustomTexture, uv); float msdf = median(sdColor.r, sdColor.g, sdColor.b); float m = uInvert == 0 ? 1.0 - sdColor.a : sdColor.a; float sd = mix(msdf, sdColor.a, m); float screenPxDistance = screenPxRange(vec2(0.0833333)) * -(sd - 0.5); return screenPxDistance; } float sdX(vec2 p, float w) { p = abs(p); float d1 = sdBox(rot(PI / 4.0) * p, vec2(w, w * 0.3)); float d2 = sdBox(rot(-PI / 4.0) * p, vec2(w, w * 0.3)); return min(d1, d2); } float sdRing(vec2 p, float r1, float r2) { return abs(length(p) - r1) - r2; } float getDistance(vec2 uv) { switch (uShape) { case 0: return sdCustom(uv); break; case 1: return sdCircle(uv, 0.4); break; case 2: return sdSquare(uv, 0.4); break; case 3: return sdEquilateralTriangle(uv, 0.4); break; case 4: return sdStar5(uv, 0.4, 0.5); break; case 5: return sdDiamond(uv, 0.4); break; case 6: return sdPentagon(uv, 0.4); break; case 7: return sdHexagon(uv, 0.4); break; case 8: return sdOctagon(uv, 0.4); break; case 9: return sdVesica(uv, 0.5); break; case 10: return sdArchway(uv, 0.3); break; case 11: return sdX(uv, 0.4); break; case 12: return sdRing(uv, 0.3, 0.1); break; case 13: return sdBox(vTextureCoord - 0.5, vec2(0.5)); break; default: return 1.0; } } float getDist(vec2 uv) { float sd = getDistance(uv); vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0); vec2 mousePos = uMousePos * aspect; float mouseDistance = length(vTextureCoord * aspect - mousePos); float falloff = smoothstep(0.0, 0.8, mouseDistance); float asd = 2.0; // #ifelseopen if (uShape == 0) { asd = 0.5; } // #ifelseclose asd = uMouseDistortDir == 0 ? -asd : asd; float md = mix(0.02 / falloff, 0.1 / falloff, -asd * sd); md = md * 1.5 * uMouseDistort; md = uMouseDistortDir == 0 ? min(-md, 0.0) : max(md, 0.0); sd -= md; return sd; } float screenPxRange() { vec2 unitRange = vec2(0.5); vec2 screenTexSize = vec2(1.0) / fwidth(vTextureCoord); return max(0.5 * dot(unitRange, screenTexSize), 1.0); } vec4 refrakt(float sd, vec2 st, vec4 bg) { // #ifelseopen if (uInvert == 1) { sd = -sd; } // #ifelseclose vec2 offset = mix(vec2(0), normalize(st) / sd, length(st)); // #ifelseopen if (uShape == 0) { offset *= 3.0; } // #ifelseclose vec4 r = vec4(0, 0, 0, 1); float rdisp = mix(0.01, 0.008, uDispersion); float gdisp = mix(0.01, 0.01, uDispersion); float bdisp = mix(0.01, 0.012, uDispersion); r.r = texture(uTexture, vTextureCoord + offset * (uRefraction - 0.5) * rdisp).r; r.g = texture(uTexture, vTextureCoord + offset * (uRefraction - 0.5) * gdisp).g; r.b = texture(uTexture, vTextureCoord + offset * (uRefraction - 0.5) * bdisp).b; float opacity = ceil(-sd); float smoothness = uShape == 0 ? 0.005 : 0.0025; opacity = smoothstep(0.0, smoothness, -sd); vec4 background = uShowBg == 0 ? vec4(0) : bg; return mix(background, r + vec4(uColor / (-sd * 50.0), 1.0) * uMix, opacity); } vec4 getEffect(vec2 st, vec4 bg) { float eps = 0.0005; float sd = getDist(st); float sd1 = getDist(st + vec2(eps, 0.0)); float sd2 = getDist(st - vec2(eps, 0.0)); float sd3 = getDist(st + vec2(0.0, eps)); float sd4 = getDist(st - vec2(0.0, eps)); vec4 r = refrakt(sd, st, bg); vec4 r1 = refrakt(sd1, st + vec2(eps, 0.0), bg); vec4 r2 = refrakt(sd2, st - vec2(eps, 0.0), bg); vec4 r3 = refrakt(sd3, st + vec2(0.0, eps), bg); vec4 r4 = refrakt(sd4, st - vec2(0.0, eps), bg); r = (r + r1 + r2 + r3 + r4) * 0.2; return r; } void main() { vec2 uv = vTextureCoord; vec4 bg = texture(uTexture, uv); vec4 color = vec4(1); vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0); vec2 mousePos = mix(vec2(0), uMousePos - 0.5, uTrackMouse); vec2 st = uv - (uPos + mousePos); st *= aspect; st *= 1.0 / (uScale + 0.2); st *= rot(uAngle * 2.0 * PI); color = getEffect(st, bg); // #ifelseopen if (uBlendMode > 0) { color.rgb = blend(uBlendMode, bg.rgb, color.rgb); } // #ifelseclose // #ifelseopen if (uIsMask == 1) { vec2 maskPos = mix(vec2(0), uMousePos - 0.5, uParentTrackMouse); vec4 maskColor = texture(uMaskTexture, vTextureCoord - maskPos); color = color * (maskColor.a * maskColor.a); } // #ifelseclose fragColor = color; } ================================================ FILE: src/shaders/vertex.glsl ================================================ #version 300 es in vec4 a_position; out vec2 v_uv; // in vec4 a_color; // out vec4 v_color; void main() { v_uv = (a_position.xy + 1.0) * 0.5; gl_Position = a_position; // v_color = a_color; } ================================================ FILE: src/shaders-wgsl/fragment-bg-hblur.wgsl ================================================ // Horizontal Gaussian blur pass for WebGPU // Ported from fragment-bg-hblur.glsl (blurs along X axis) const MAX_BLUR_RADIUS: i32 = 200; struct BlurUniforms { u_resolution: vec2f, u_blurRadius: i32, _pad: i32, }; @group(0) @binding(0) var u: BlurUniforms; @group(0) @binding(1) var u_prevPassTexture: texture_2d; @group(0) @binding(2) var u_sampler: sampler; @group(0) @binding(3) var u_blurWeights: array; @fragment fn fs_main(@location(0) v_uv: vec2f) -> @location(0) vec4f { let texelSize = 1.0 / u.u_resolution; var color = textureSampleLevel(u_prevPassTexture, u_sampler, v_uv, 0.0) * u_blurWeights[0]; for (var i: i32 = 1; i <= u.u_blurRadius; i = i + 1) { if (i > MAX_BLUR_RADIUS) { break; } let w = u_blurWeights[i]; let offset_x = f32(i) * texelSize.x; color += textureSampleLevel(u_prevPassTexture, u_sampler, v_uv + vec2f(offset_x, 0.0), 0.0) * w; color += textureSampleLevel(u_prevPassTexture, u_sampler, v_uv - vec2f(offset_x, 0.0), 0.0) * w; } return color; } ================================================ FILE: src/shaders-wgsl/fragment-bg-vblur.wgsl ================================================ // Vertical Gaussian blur pass for WebGPU // Ported from fragment-bg-vblur.glsl (blurs along Y axis) const MAX_BLUR_RADIUS: i32 = 200; struct BlurUniforms { u_resolution: vec2f, u_blurRadius: i32, _pad: i32, }; @group(0) @binding(0) var u: BlurUniforms; @group(0) @binding(1) var u_prevPassTexture: texture_2d; @group(0) @binding(2) var u_sampler: sampler; @group(0) @binding(3) var u_blurWeights: array; @fragment fn fs_main(@location(0) v_uv: vec2f) -> @location(0) vec4f { let texelSize = 1.0 / u.u_resolution; var color = textureSampleLevel(u_prevPassTexture, u_sampler, v_uv, 0.0) * u_blurWeights[0]; for (var i: i32 = 1; i <= u.u_blurRadius; i = i + 1) { if (i > MAX_BLUR_RADIUS) { break; } let w = u_blurWeights[i]; let offset_y = f32(i) * texelSize.y; color += textureSampleLevel(u_prevPassTexture, u_sampler, v_uv + vec2f(0.0, offset_y), 0.0) * w; color += textureSampleLevel(u_prevPassTexture, u_sampler, v_uv - vec2f(0.0, offset_y), 0.0) * w; } return color; } ================================================ FILE: src/shaders-wgsl/fragment-bg.wgsl ================================================ // Background pass fragment shader for WebGPU // Ported from fragment-bg.glsl struct Uniforms { u_resolution: vec2f, u_dpr: f32, _pad0: f32, u_mouse: vec2f, u_mouseSpring: vec2f, u_shapeWidth: f32, u_shapeHeight: f32, u_shapeRadius: f32, u_shapeRoundness: f32, u_mergeRate: f32, u_glareAngle: f32, u_shadowExpand: f32, u_shadowFactor: f32, u_shadowPosition: vec2f, u_bgTextureRatio: f32, u_bgType: i32, u_bgTextureReady: i32, u_showShape1: i32, u_blurRadius: i32, u_blurEdge: i32, // main pass uniforms (unused here but in same buffer) u_tint: vec4f, u_refThickness: f32, u_refFactor: f32, u_refDispersion: f32, u_refFresnelRange: f32, u_refFresnelHardness: f32, u_refFresnelFactor: f32, u_glareRange: f32, u_glareHardness: f32, u_glareConvergence: f32, u_glareOppositeFactor: f32, u_glareFactor: f32, _pad1: f32, }; @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var u_bgTexture: texture_2d; @group(0) @binding(2) var u_sampler: sampler; fn chessboard(uv: vec2f, size: f32, mode: i32) -> f32 { let yBars = step(size * 2.0, (uv.y * 2.0) % (size * 4.0)); let xBars = step(size * 2.0, (uv.x * 2.0) % (size * 4.0)); if (mode == 0) { return yBars; } else if (mode == 1) { return xBars; } else { return abs(yBars - xBars); } } fn halfColor(uv: vec2f) -> f32 { if (uv.y > 0.5) { return 1.0; } else { return 0.0; } } fn sdCircle(p: vec2f, r: f32) -> f32 { return length(p) - r; } fn superellipseCornerSDF(p_in: vec2f, r: f32, n: f32) -> f32 { let p = abs(p_in); let v = pow(pow(p.x, n) + pow(p.y, n), 1.0 / n); return v - r; } fn roundedRectSDF(p_in: vec2f, center: vec2f, width: f32, height: f32, cornerRadius: f32, n: f32) -> f32 { let p = p_in - center; let cr = cornerRadius * u.u_dpr; let d = abs(p) - vec2f(width * u.u_dpr, height * u.u_dpr) * 0.5; var dist: f32; if (d.x > -cr && d.y > -cr) { let cornerCenter = sign(p) * (vec2f(width * u.u_dpr, height * u.u_dpr) * 0.5 - vec2f(cr)); let cornerP = p - cornerCenter; dist = superellipseCornerSDF(cornerP, cr, n); } else { dist = min(max(d.x, d.y), 0.0) + length(max(d, vec2f(0.0))); } return dist; } fn smin(a: f32, b: f32, k: f32) -> f32 { let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } fn mainSDF(p1: vec2f, p2: vec2f, p: vec2f) -> f32 { let p1n = p1 + p / u.u_resolution.y; let p2n = p2 + p / u.u_resolution.y; var d1: f32; if (u.u_showShape1 == 1) { d1 = sdCircle(p1n, 100.0 * u.u_dpr / u.u_resolution.y); } else { d1 = 1.0; } let d2 = roundedRectSDF( p2n, vec2f(0.0), u.u_shapeWidth / u.u_resolution.y, u.u_shapeHeight / u.u_resolution.y, u.u_shapeRadius / u.u_resolution.y, u.u_shapeRoundness ); return smin(d1, d2, u.u_mergeRate); } fn getCoverUV(uv_in: vec2f, canvasAspect: f32, textureAspect: f32) -> vec2f { var uv = uv_in; if (canvasAspect > textureAspect) { let scale = textureAspect / canvasAspect; uv.y = uv.y * scale + 0.5 - 0.5 * scale; } else { let scale = canvasAspect / textureAspect; uv.x = uv.x * scale + 0.5 - 0.5 * scale; } return uv; } @fragment fn fs_main(@builtin(position) frag_coord: vec4f, @location(0) v_uv: vec2f) -> @location(0) vec4f { let u_resolution1x = u.u_resolution / u.u_dpr; var bgColor = vec3f(1.0); // frag_coord.y is top-down in WebGPU; flip to get GLSL-style bottom-up pixel coords let pixel = vec2f(frag_coord.x, u.u_resolution.y - frag_coord.y); // v_uv has y=0 at top (WebGPU convention); compute GLSL-style uv with y=0 at bottom let gl_uv = vec2f(v_uv.x, 1.0 - v_uv.y); if (u.u_bgType <= 0) { bgColor = vec3f(1.0 - chessboard(pixel / u.u_dpr, 20.0, 2) / 4.0); } else if (u.u_bgType <= 1) { if (gl_uv.x < 0.5 && gl_uv.y > 0.5) { bgColor = vec3f(chessboard(pixel / u.u_dpr, 10.0, 0)); } else if (gl_uv.x > 0.5 && gl_uv.y < 0.5) { bgColor = vec3f(chessboard(pixel / u.u_dpr, 10.0, 1)); } else if (gl_uv.x < 0.5 && gl_uv.y < 0.5) { bgColor = vec3f(0.0); } } else if (u.u_bgType <= 2) { bgColor = vec3f(halfColor(pixel / u.u_resolution) * 0.6 + 0.3); } else if (u.u_bgType <= 11) { if (u.u_bgTextureReady != 1) { bgColor = vec3f(1.0 - chessboard(pixel / u.u_dpr, 20.0, 2) / 4.0); } else { // v_uv matches WebGPU texture coords (y=0 at top), correct for texture sampling let uv = getCoverUV(v_uv, u.u_resolution.x / u.u_resolution.y, u.u_bgTextureRatio); bgColor = textureSampleLevel(u_bgTexture, u_sampler, uv, 0.0).rgb; } } // shadow — uses pixel (GLSL-style bottom-up coords) for SDF let p1 = (vec2f(0.0) - u.u_resolution * 0.5 + vec2f(u.u_shadowPosition.x * u.u_dpr, u.u_shadowPosition.y * u.u_dpr)) / u.u_resolution.y; let p2 = (vec2f(0.0) - u.u_mouseSpring + vec2f(u.u_shadowPosition.x * u.u_dpr, u.u_shadowPosition.y * u.u_dpr)) / u.u_resolution.y; let merged = mainSDF(p1, p2, pixel); let shadow = exp(-1.0 / u.u_shadowExpand * abs(merged) * u_resolution1x.y) * 0.6 * u.u_shadowFactor; return vec4f(bgColor - vec3f(shadow), 1.0); } ================================================ FILE: src/shaders-wgsl/fragment-main.wgsl ================================================ // Main glass effect composition shader for WebGPU // Ported from fragment-main.glsl (STEP==9 branch only) const PI: f32 = 3.14159265359; const N_R: f32 = 0.98; // 1.0 - 0.02 const N_G: f32 = 1.0; const N_B: f32 = 1.02; // 1.0 + 0.02 struct Uniforms { u_resolution: vec2f, u_dpr: f32, _pad0: f32, u_mouse: vec2f, u_mouseSpring: vec2f, u_shapeWidth: f32, u_shapeHeight: f32, u_shapeRadius: f32, u_shapeRoundness: f32, u_mergeRate: f32, u_glareAngle: f32, u_shadowExpand: f32, u_shadowFactor: f32, u_shadowPosition: vec2f, u_bgTextureRatio: f32, u_bgType: i32, u_bgTextureReady: i32, u_showShape1: i32, u_blurRadius: i32, u_blurEdge: i32, u_tint: vec4f, u_refThickness: f32, u_refFactor: f32, u_refDispersion: f32, u_refFresnelRange: f32, u_refFresnelHardness: f32, u_refFresnelFactor: f32, u_glareRange: f32, u_glareHardness: f32, u_glareConvergence: f32, u_glareOppositeFactor: f32, u_glareFactor: f32, _pad1: f32, }; @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var u_blurredBg: texture_2d; @group(0) @binding(2) var u_bg: texture_2d; @group(0) @binding(3) var u_sampler: sampler; // ---- SDF functions ---- fn sdCircle(p: vec2f, r: f32) -> f32 { return length(p) - r; } fn superellipseCornerSDF(p_in: vec2f, r: f32, n: f32) -> f32 { let p = abs(p_in); let v = pow(pow(p.x, n) + pow(p.y, n), 1.0 / n); return v - r; } fn roundedRectSDF(p_in: vec2f, center: vec2f, width: f32, height: f32, cornerRadius: f32, n: f32) -> f32 { let p = p_in - center; let cr = cornerRadius * u.u_dpr; let d = abs(p) - vec2f(width * u.u_dpr, height * u.u_dpr) * 0.5; var dist: f32; if (d.x > -cr && d.y > -cr) { let cornerCenter = sign(p) * (vec2f(width * u.u_dpr, height * u.u_dpr) * 0.5 - vec2f(cr)); let cornerP = p - cornerCenter; dist = superellipseCornerSDF(cornerP, cr, n); } else { dist = min(max(d.x, d.y), 0.0) + length(max(d, vec2f(0.0))); } return dist; } fn smin(a: f32, b: f32, k: f32) -> f32 { let h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } fn mainSDF(p1: vec2f, p2: vec2f, p: vec2f) -> f32 { let p1n = p1 + p / u.u_resolution.y; let p2n = p2 + p / u.u_resolution.y; var d1: f32; if (u.u_showShape1 == 1) { d1 = sdCircle(p1n, 100.0 * u.u_dpr / u.u_resolution.y); } else { d1 = 1.0; } let d2 = roundedRectSDF( p2n, vec2f(0.0), u.u_shapeWidth / u.u_resolution.y, u.u_shapeHeight / u.u_resolution.y, u.u_shapeRadius / u.u_resolution.y, u.u_shapeRoundness ); return smin(d1, d2, u.u_mergeRate); } fn getNormal(p1: vec2f, p2: vec2f, p: vec2f) -> vec2f { // dFdx(gl_FragCoord.x) = 1.0 on a fullscreen quad, so eps = 1.0 let h = vec2f(1.0, 1.0); let grad = vec2f( mainSDF(p1, p2, p + vec2f(h.x, 0.0)) - mainSDF(p1, p2, p - vec2f(h.x, 0.0)), mainSDF(p1, p2, p + vec2f(0.0, h.y)) - mainSDF(p1, p2, p - vec2f(0.0, h.y)) ) / (2.0 * h); return grad * 1.414213562 * 1000.0; } // Safe normalize: returns zero vector instead of NaN when length is near zero fn safeNormalize(v: vec2f) -> vec2f { let len = length(v); if (len < 1e-8) { return vec2f(0.0); } return v / len; } // ---- Color space functions (sRGB <-> LCH via Lab) ---- const D65_WHITE: vec3f = vec3f(0.95045592705, 1.0, 1.08905775076); // GLSL mat3 is column-major. For `v * M`, result[j] = dot(v, column[j]). // GLSL: mat3(col0.x,col0.y,col0.z, col1.x,col1.y,col1.z, col2.x,col2.y,col2.z) // RGB_TO_XYZ_M columns: const RGB_TO_XYZ_M_COL0: vec3f = vec3f(0.4124, 0.3576, 0.1805); const RGB_TO_XYZ_M_COL1: vec3f = vec3f(0.2126, 0.7152, 0.0722); const RGB_TO_XYZ_M_COL2: vec3f = vec3f(0.0193, 0.1192, 0.9505); // XYZ_TO_RGB_M columns: const XYZ_TO_RGB_M_COL0: vec3f = vec3f(3.2406255, -1.537208, -0.4986286); const XYZ_TO_RGB_M_COL1: vec3f = vec3f(-0.9689307, 1.8757561, 0.0415175); const XYZ_TO_RGB_M_COL2: vec3f = vec3f(0.0557101, -0.2040211, 1.0569959); fn UNCOMPAND_SRGB(a: f32) -> f32 { if (a > 0.04045) { return pow((a + 0.055) / 1.055, 2.4); } return a / 12.92; } fn COMPAND_RGB(a: f32) -> f32 { if (a <= 0.0031308) { return 12.92 * a; } return 1.055 * pow(a, 0.41666666666) - 0.055; } fn SRGB_TO_RGB(srgb: vec3f) -> vec3f { return vec3f(UNCOMPAND_SRGB(srgb.x), UNCOMPAND_SRGB(srgb.y), UNCOMPAND_SRGB(srgb.z)); } fn RGB_TO_SRGB(rgb: vec3f) -> vec3f { return vec3f(COMPAND_RGB(rgb.x), COMPAND_RGB(rgb.y), COMPAND_RGB(rgb.z)); } fn RGB_TO_XYZ(rgb: vec3f) -> vec3f { // Matrix multiply using column vectors (GLSL mat3 is column-major) return vec3f( dot(rgb, RGB_TO_XYZ_M_COL0), dot(rgb, RGB_TO_XYZ_M_COL1), dot(rgb, RGB_TO_XYZ_M_COL2) ); } fn XYZ_TO_RGB(xyz: vec3f) -> vec3f { return vec3f( dot(xyz, XYZ_TO_RGB_M_COL0), dot(xyz, XYZ_TO_RGB_M_COL1), dot(xyz, XYZ_TO_RGB_M_COL2) ); } fn SRGB_TO_XYZ(srgb: vec3f) -> vec3f { return RGB_TO_XYZ(SRGB_TO_RGB(srgb)); } fn XYZ_TO_SRGB(xyz: vec3f) -> vec3f { return RGB_TO_SRGB(XYZ_TO_RGB(xyz)); } fn XYZ_TO_LAB_F(x: f32) -> f32 { if (x > 0.00885645167) { return pow(x, 0.333333333); } return 7.78703703704 * x + 0.13793103448; } fn XYZ_TO_LAB(xyz: vec3f) -> vec3f { let xyz_scaled = vec3f( XYZ_TO_LAB_F(xyz.x / D65_WHITE.x), XYZ_TO_LAB_F(xyz.y / D65_WHITE.y), XYZ_TO_LAB_F(xyz.z / D65_WHITE.z) ); return vec3f( 116.0 * xyz_scaled.y - 16.0, 500.0 * (xyz_scaled.x - xyz_scaled.y), 200.0 * (xyz_scaled.y - xyz_scaled.z) ); } fn SRGB_TO_LAB(srgb: vec3f) -> vec3f { return XYZ_TO_LAB(SRGB_TO_XYZ(srgb)); } fn LAB_TO_LCH(Lab: vec3f) -> vec3f { return vec3f(Lab.x, sqrt(dot(Lab.yz, Lab.yz)), atan2(Lab.z, Lab.y) * 57.2957795131); } fn SRGB_TO_LCH(srgb: vec3f) -> vec3f { return LAB_TO_LCH(SRGB_TO_LAB(srgb)); } fn LAB_TO_XYZ_F(x: f32) -> f32 { if (x > 0.206897) { return x * x * x; } return 0.12841854934 * (x - 0.137931034); } fn LAB_TO_XYZ(Lab: vec3f) -> vec3f { let w = (Lab.x + 16.0) / 116.0; return D65_WHITE * vec3f(LAB_TO_XYZ_F(w + Lab.y / 500.0), LAB_TO_XYZ_F(w), LAB_TO_XYZ_F(w - Lab.z / 200.0)); } fn LAB_TO_SRGB(lab: vec3f) -> vec3f { return XYZ_TO_SRGB(LAB_TO_XYZ(lab)); } fn LCH_TO_LAB(LCh: vec3f) -> vec3f { return vec3f(LCh.x, LCh.y * cos(LCh.z * 0.01745329251), LCh.y * sin(LCh.z * 0.01745329251)); } fn LCH_TO_SRGB(lch: vec3f) -> vec3f { return LAB_TO_SRGB(LCH_TO_LAB(lch)); } fn vec2ToAngle(v: vec2f) -> f32 { var angle = atan2(v.y, v.x); if (angle < 0.0) { angle += 2.0 * PI; } return angle; } // ---- Texture dispersion ---- fn getTextureDispersion(v_uv: vec2f, mixRate: f32, offset: vec2f, factor: f32) -> vec4f { var pixel = vec4f(1.0); let bgR = textureSampleLevel(u_bg, u_sampler, v_uv + offset * (1.0 - (N_R - 1.0) * factor), 0.0).r; let bgG = textureSampleLevel(u_bg, u_sampler, v_uv + offset * (1.0 - (N_G - 1.0) * factor), 0.0).g; let bgB = textureSampleLevel(u_bg, u_sampler, v_uv + offset * (1.0 - (N_B - 1.0) * factor), 0.0).b; let blurR = textureSampleLevel(u_blurredBg, u_sampler, v_uv + offset * (1.0 - (N_R - 1.0) * factor), 0.0).r; let blurG = textureSampleLevel(u_blurredBg, u_sampler, v_uv + offset * (1.0 - (N_G - 1.0) * factor), 0.0).g; let blurB = textureSampleLevel(u_blurredBg, u_sampler, v_uv + offset * (1.0 - (N_B - 1.0) * factor), 0.0).b; pixel.r = mix(bgR, blurR, mixRate); pixel.g = mix(bgG, blurG, mixRate); pixel.b = mix(bgB, blurB, mixRate); return pixel; } // ---- Main fragment shader (STEP==9 equivalent) ---- @fragment fn fs_main(@builtin(position) frag_coord: vec4f, @location(0) v_uv: vec2f) -> @location(0) vec4f { let u_resolution1x = u.u_resolution / u.u_dpr; // WebGPU frag_coord.y is top-down, flip to match GLSL bottom-up convention let pixel = vec2f(frag_coord.x, u.u_resolution.y - frag_coord.y); // center of shape 1 let p1 = (vec2f(0.0) - u.u_resolution * 0.5) / u.u_resolution.y; // center of shape 2 let p2 = (vec2f(0.0) - u.u_mouseSpring) / u.u_resolution.y; // merged shape let merged = mainSDF(p1, p2, pixel); var outColor: vec4f; if (merged < 0.005) { let nmerged = -1.0 * (merged * u_resolution1x.y); // calculate refraction edge factor let x_R_ratio = 1.0 - nmerged / u.u_refThickness; // Clamp asin inputs to [-1,1] — in GLSL asin silently clamps, in WGSL it returns NaN let thetaI = asin(clamp(pow(x_R_ratio, 2.0), -1.0, 1.0)); let thetaT = asin(clamp(1.0 / u.u_refFactor * sin(thetaI), -1.0, 1.0)); var edgeFactor = -1.0 * tan(thetaT - thetaI); if (nmerged >= u.u_refThickness) { edgeFactor = 0.0; } if (edgeFactor <= 0.0) { outColor = textureSampleLevel(u_blurredBg, u_sampler, v_uv, 0.0); outColor = mix(outColor, vec4f(u.u_tint.r, u.u_tint.g, u.u_tint.b, 1.0), u.u_tint.a * 0.8); } else { let edgeH = nmerged / u.u_refThickness; let normal = getNormal(p1, p2, pixel); var blurMixRate: f32; if (u.u_blurEdge > 0) { blurMixRate = 1.0; } else { blurMixRate = edgeH; } // Normal is in GLSL bottom-up coords (Y up), but v_uv.y is top-down (Y down). // Flip the Y component of the offset to match v_uv orientation. let refOffset = -normal * edgeFactor * 0.05 * u.u_dpr * vec2f( u.u_resolution.y / (u_resolution1x.x * u.u_dpr), 1.0 ); let blurredPixel = getTextureDispersion( v_uv, blurMixRate, vec2f(refOffset.x, -refOffset.y), u.u_refDispersion ); // basic tint outColor = mix(blurredPixel, vec4f(u.u_tint.r, u.u_tint.g, u.u_tint.b, 1.0), u.u_tint.a * 0.8); // add fresnel let fresnelFactor = clamp( pow( 1.0 + merged * u_resolution1x.y / 1500.0 * pow(500.0 / u.u_refFresnelRange, 2.0) + u.u_refFresnelHardness, 5.0 ), 0.0, 1.0 ); var fresnelTintLCH = SRGB_TO_LCH( mix(vec3f(1.0), vec3f(u.u_tint.r, u.u_tint.g, u.u_tint.b), u.u_tint.a * 0.5) ); fresnelTintLCH.x += 20.0 * fresnelFactor * u.u_refFresnelFactor; fresnelTintLCH.x = clamp(fresnelTintLCH.x, 0.0, 100.0); outColor = mix( outColor, vec4f(LCH_TO_SRGB(fresnelTintLCH), 1.0), fresnelFactor * u.u_refFresnelFactor * 0.7 * length(normal) ); // add glare let glareGeoFactor = clamp( pow( 1.0 + merged * u_resolution1x.y / 1500.0 * pow(500.0 / u.u_glareRange, 2.0) + u.u_glareHardness, 5.0 ), 0.0, 1.0 ); let glareAngle = (vec2ToAngle(safeNormalize(normal)) - PI / 4.0 + u.u_glareAngle) * 2.0; var glareFarside: i32 = 0; if ((glareAngle > PI * (2.0 - 0.5) && glareAngle < PI * (4.0 - 0.5)) || glareAngle < PI * (0.0 - 0.5)) { glareFarside = 1; } var glareSideFactor: f32; if (glareFarside == 1) { glareSideFactor = 1.2 * u.u_glareOppositeFactor; } else { glareSideFactor = 1.2; } var glareAngleFactor = (0.5 + sin(glareAngle) * 0.5) * glareSideFactor * u.u_glareFactor; glareAngleFactor = clamp(pow(glareAngleFactor, 0.1 + u.u_glareConvergence * 2.0), 0.0, 1.0); var glareTintLCH = SRGB_TO_LCH( mix(blurredPixel.rgb, vec3f(u.u_tint.r, u.u_tint.g, u.u_tint.b), u.u_tint.a * 0.5) ); glareTintLCH.x += 150.0 * glareAngleFactor * glareGeoFactor; glareTintLCH.y += 30.0 * glareAngleFactor * glareGeoFactor; glareTintLCH.x = clamp(glareTintLCH.x, 0.0, 120.0); outColor = mix( outColor, vec4f(LCH_TO_SRGB(glareTintLCH), 1.0), glareAngleFactor * glareGeoFactor * length(normal) ); } } else { outColor = textureSampleLevel(u_bg, u_sampler, v_uv, 0.0); } // smooth edge transition outColor = mix(outColor, textureSampleLevel(u_bg, u_sampler, v_uv, 0.0), smoothstep(-0.001, 0.001, merged)); return outColor; } ================================================ FILE: src/shaders-wgsl/vertex.wgsl ================================================ // Fullscreen quad vertex shader for WebGPU // Similar to vertex.glsl but with v_uv.y flipped so that v_uv=(0,0) is top-left, // matching WebGPU's texture/framebuffer coordinate convention. struct VertexOutput { @builtin(position) position: vec4f, @location(0) uv: vec2f, }; @vertex fn vs_main(@location(0) a_position: vec2f) -> VertexOutput { var out: VertexOutput; let uv = (a_position + 1.0) * 0.5; // Flip Y: in WebGPU, texture v=0 is top, but clip y=+1 is top. // So top of screen has uv.y=0, bottom has uv.y=1. out.uv = vec2f(uv.x, 1.0 - uv.y); out.position = vec4f(a_position, 0.0, 1.0); return out; } ================================================ FILE: src/utils/GLUtils.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ // 基础类型定义 type GL = WebGL2RenderingContext; interface ShaderSource { vertex: string; fragment: string; } interface AttributeInfo { location: number; size: number; type: number; } interface UniformInfo { location: WebGLUniformLocation; type: number; value: any; isArray: false | { size: number; }; } interface RenderPassConfig { name: string; shader: ShaderSource; inputs?: { [uniformName: string]: string }; outputToScreen?: boolean; } // 着色器程序类 export class ShaderProgram { private gl: GL; private program: WebGLProgram; private uniforms: Map = new Map(); private attributes: Map = new Map(); constructor(gl: GL, source: ShaderSource) { this.gl = gl; this.program = this.createProgram(source); this.detectAttributes(); this.detectUniforms(); } private createShader(type: number, source: string): WebGLShader { const gl = this.gl; const shader = gl.createShader(type); if (!shader) throw new Error("Failed to create shader"); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const info = gl.getShaderInfoLog(shader); gl.deleteShader(shader); throw new Error(`Shader compile error: ${info}`); } return shader; } private createProgram(source: ShaderSource): WebGLProgram { const gl = this.gl; const program = gl.createProgram(); if (!program) throw new Error("Failed to create program"); const vertexShader = this.createShader(gl.VERTEX_SHADER, source.vertex); const fragmentShader = this.createShader( gl.FRAGMENT_SHADER, source.fragment ); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { const info = gl.getProgramInfoLog(program); gl.deleteProgram(program); throw new Error(`Program link error: ${info}`); } gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); return program; } private detectAttributes(): void { const gl = this.gl; const numAttributes = gl.getProgramParameter( this.program, gl.ACTIVE_ATTRIBUTES ); for (let i = 0; i < numAttributes; i++) { const info = gl.getActiveAttrib(this.program, i); if (!info) continue; const location = gl.getAttribLocation(this.program, info.name); this.attributes.set(info.name, { location, size: info.size, type: info.type, }); } } private detectUniforms(): void { const gl = this.gl; const numUniforms = gl.getProgramParameter( this.program, gl.ACTIVE_UNIFORMS ); for (let i = 0; i < numUniforms; i++) { const info = gl.getActiveUniform(this.program, i); if (!info) continue; const location = gl.getUniformLocation(this.program, info.name); if (!location) continue; const originalName = info.name; const arrayRegex = /\[\d+\]$/; if (arrayRegex.test(originalName)) { const baseName = originalName.replace(arrayRegex, ''); this.uniforms.set(baseName, { location, type: info.type, value: null, isArray: { size: info.size } }) } else { this.uniforms.set(info.name, { location, type: info.type, value: null, isArray: false }); } } } public use(): void { this.gl.useProgram(this.program); } public setUniform(name: string, value: any): void { const gl = this.gl; const uniformInfo = this.uniforms.get(name); if (!uniformInfo) return; const location = uniformInfo.location; if (uniformInfo.isArray && Array.isArray(value)) { switch (uniformInfo.type) { case gl.FLOAT: gl.uniform1fv(uniformInfo.location, value); break; case gl.FLOAT_VEC2: gl.uniform2fv(uniformInfo.location, value); break; case gl.FLOAT_VEC3: gl.uniform3fv(uniformInfo.location, value); break; case gl.FLOAT_VEC4: gl.uniform4fv(uniformInfo.location, value); break; // 添加其他类型的处理... } } else { switch (uniformInfo.type) { case gl.FLOAT: gl.uniform1f(location, value); break; case gl.FLOAT_VEC2: gl.uniform2fv(location, value); break; case gl.FLOAT_VEC3: gl.uniform3fv(location, value); break; case gl.FLOAT_VEC4: gl.uniform4fv(location, value); break; case gl.INT: gl.uniform1i(location, value); break; case gl.SAMPLER_2D: gl.uniform1i(location, value); break; case gl.FLOAT_MAT3: gl.uniformMatrix3fv(location, false, value); break; case gl.FLOAT_MAT4: gl.uniformMatrix4fv(location, false, value); break; // case gl.ARRAY } } } public getAttributeLocation(name: string): number { const attribute = this.attributes.get(name); return attribute ? attribute.location : -1; } public dispose(): void { const gl = this.gl; // 删除着色器程序 if (this.program) { // 获取附加的着色器 const shaders = gl.getAttachedShaders(this.program); // 删除每个着色器 if (shaders) { shaders.forEach(shader => { gl.deleteShader(shader); }); } // 删除程序 gl.deleteProgram(this.program); } // 清理映射 this.uniforms.clear(); this.attributes.clear(); } } // 帧缓冲区类 export class FrameBuffer { private gl: GL; private fbo: WebGLFramebuffer; private texture: WebGLTexture; private depthTexture: WebGLTexture; private width: number; private height: number; constructor(gl: GL, width: number, height: number) { this.gl = gl; this.width = width; this.height = height; // 创建FBO和附件 const { fbo, texture, depthTexture } = this.createFramebuffer(); this.fbo = fbo; this.texture = texture; this.depthTexture = depthTexture; } private createFramebuffer() { const gl = this.gl; // 创建并绑定FBO const fbo = gl.createFramebuffer(); if (!fbo) throw new Error("Failed to create framebuffer"); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); // 创建颜色附件 const texture = gl.createTexture(); if (!texture) throw new Error("Failed to create texture"); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA16F, this.width, this.height, 0, gl.RGBA, gl.FLOAT, null ); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0 ); // 创建深度附件 const depthTexture = gl.createTexture(); if (!depthTexture) throw new Error("Failed to create depth texture"); gl.bindTexture(gl.TEXTURE_2D, depthTexture); gl.texImage2D( gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, this.width, this.height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null ); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0 ); // 检查FBO状态 const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); if (status !== gl.FRAMEBUFFER_COMPLETE) { throw new Error(`Framebuffer is incomplete: ${status}`); } // 解绑 gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.bindTexture(gl.TEXTURE_2D, null); return { fbo, texture, depthTexture }; } public bind(): void { this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.fbo); } public unbind(): void { this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); } public getTexture(): WebGLTexture { return this.texture; } public getDepthTexture(): WebGLTexture { return this.depthTexture; } public resize(width: number, height: number): void { this.width = width; this.height = height; // 重新创建纹理附件 this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture); this.gl.texImage2D( this.gl.TEXTURE_2D, 0, this.gl.RGBA16F, width, height, 0, this.gl.RGBA, this.gl.FLOAT, null ); this.gl.bindTexture(this.gl.TEXTURE_2D, this.depthTexture); this.gl.texImage2D( this.gl.TEXTURE_2D, 0, this.gl.DEPTH_COMPONENT24, width, height, 0, this.gl.DEPTH_COMPONENT, this.gl.UNSIGNED_INT, null ); this.gl.bindTexture(this.gl.TEXTURE_2D, null); } public dispose(): void { const gl = this.gl; gl.deleteFramebuffer(this.fbo); gl.deleteTexture(this.texture); gl.deleteTexture(this.depthTexture); } } // 渲染通道类 export class RenderPass { private gl: GL; private program: ShaderProgram; private frameBuffer: FrameBuffer | null; private vao: WebGLVertexArrayObject; public config: RenderPassConfig; constructor( gl: GL, shaderSource: ShaderSource, outputToScreen: boolean = false ) { this.gl = gl; this.config = { name: "", shader: shaderSource }; this.program = new ShaderProgram(gl, shaderSource); this.frameBuffer = !outputToScreen ? new FrameBuffer(gl, gl.canvas.width, gl.canvas.height) : null; this.vao = this.createVAO(); } private createVAO(): WebGLVertexArrayObject { const gl = this.gl; // 创建并绑定VAO const vao = gl.createVertexArray(); if (!vao) throw new Error("Failed to create VAO"); gl.bindVertexArray(vao); // 创建并设置顶点缓冲区 const buffer = gl.createBuffer(); if (!buffer) throw new Error("Failed to create buffer"); const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); // 设置顶点属性 const positionLoc = this.program.getAttributeLocation("a_position"); gl.enableVertexAttribArray(positionLoc); gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0); // 解绑 gl.bindVertexArray(null); gl.bindBuffer(gl.ARRAY_BUFFER, null); return vao; } public setConfig(config: RenderPassConfig) { this.config = config; } public render(uniforms?: Record): void { const gl = this.gl; // 绑定FBO if (this.frameBuffer) { this.frameBuffer.bind(); } else { gl.bindFramebuffer(gl.FRAMEBUFFER, null); } // 使用着色器程序 this.program.use(); // 设置uniforms if (uniforms) { let textureCount = 0; Object.entries(uniforms).forEach(([name, value]) => { if (value instanceof WebGLTexture) { gl.activeTexture(gl.TEXTURE0 + textureCount); gl.bindTexture(gl.TEXTURE_2D, value); this.program.setUniform(name, textureCount); // 绑定为纹理单元编号 textureCount += 1; } else { this.program.setUniform(name, value); } }); } // 绑定VAO并绘制 gl.bindVertexArray(this.vao); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.bindVertexArray(null); // 解绑FBO if (this.frameBuffer) { this.frameBuffer.unbind(); } } public getOutputTexture(): WebGLTexture | null { return this.frameBuffer ? this.frameBuffer.getTexture() : null; } public resize(width: number, height: number): void { if (this.frameBuffer) { this.frameBuffer.resize(width, height); } } public dispose(): void { if (this.frameBuffer) { this.frameBuffer.dispose(); } this.program.dispose(); // 获取并删除顶点缓冲区 const gl = this.gl; gl.bindVertexArray(this.vao); const buffer = gl.getVertexAttrib(0, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING); gl.bindBuffer(gl.ARRAY_BUFFER, null); gl.deleteBuffer(buffer); gl.deleteVertexArray(this.vao); } } // 多通道渲染器类 export class MultiPassRenderer { private gl: GL; private passes: Map = new Map(); private passesArray: RenderPass[] = []; private globalUniforms: Record = {}; constructor(canvas: HTMLCanvasElement, configs: RenderPassConfig[]) { const gl = canvas.getContext("webgl2"); if (!gl) throw new Error("WebGL 2 not supported"); // 检查浮点纹理扩展 const ext = gl.getExtension("EXT_color_buffer_float"); if (!ext) throw new Error("EXT_color_buffer_float not supported"); this.gl = gl; const passesArray: typeof this.passesArray = [] for (const [index, cfg] of configs.entries()) { const pass = new RenderPass(gl, cfg.shader, cfg.outputToScreen); pass.setConfig(cfg); this.passes.set(cfg.name, pass); passesArray[index] = pass; } this.passesArray = passesArray; } public resize(width: number, height: number): void { this.passesArray.forEach((pass) => { pass.resize(width, height); }); } /** * 设置全局uniform,将应用于所有渲染通道 * @param name uniform名称 * @param value uniform值 */ public setUniform(name: string, value: any): void { this.globalUniforms[name] = value; } /** * 批量设置全局uniforms * @param uniforms uniform对象 */ public setUniforms(uniforms: Record): void { Object.assign(this.globalUniforms, uniforms); } /** * 清除特定的全局uniform * @param name uniform名称 */ public clearUniform(name: string): void { delete this.globalUniforms[name]; } /** * 清除所有全局uniforms */ public clearAllUniforms(): void { this.globalUniforms = {}; } public render(passUniforms?: Record[] | Record>): void { // const gl = this.gl; this.passesArray.forEach((pass, index) => { // 合并全局uniforms和通道特定uniforms const uniforms: Record = { ...this.globalUniforms }; // 添加通道特定的uniforms(如果有) if (passUniforms) { if (Array.isArray(passUniforms)) { Object.assign(uniforms, passUniforms[index]); } else { Object.assign(uniforms, passUniforms[pass.config.name] ?? null); } } // 添加输入纹理 if (pass.config.inputs) { Object.entries(pass.config.inputs).forEach(([uniformName, fromPassName]) => { const fromPass = this.passes.get(fromPassName); uniforms[uniformName] = fromPass?.getOutputTexture(); }) } pass.render(uniforms); // 渲染后解绑纹理 // if (index > 0) { // gl.bindTexture(gl.TEXTURE_2D, null); // } }); } /** * 清理所有渲染资源 */ public dispose(): void { const gl = this.gl; // 清理所有渲染通道 this.passes.forEach(pass => { pass.dispose(); }); this.passes.clear(); this.clearAllUniforms(); // 解绑当前绑定的任何缓冲区 gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.bindTexture(gl.TEXTURE_2D, null); } } // 加载外部纹理 export function loadTextureFromURL(gl: WebGL2RenderingContext, url: string): Promise<{ texture: WebGLTexture, ratio: number }> { return new Promise((resolve, reject) => { const image = new Image(); image.crossOrigin = ""; // 可根据需要设为 'anonymous' image.onload = () => { const texture = gl.createTexture(); if (!texture) return reject("Failed to create texture"); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image ); gl.generateMipmap(gl.TEXTURE_2D); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); resolve({ texture, ratio: image.naturalWidth / image.naturalHeight }); }; image.onerror = reject; image.src = url; }); } export function createEmptyTexture(gl: WebGL2RenderingContext): WebGLTexture { const texture = gl.createTexture(); if (!texture) throw new Error("Failed to create texture"); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // 不设置图像数据,留空,后续每帧调用 texImage2D(video) 更新 return texture; } /** * 每帧将视频帧上传至 GPU 纹理。 * @param gl WebGL2 上下文 * @param texture WebGLTexture,需要事先 create 并配置好参数 * @param video HTMLVideoElement,正在播放的视频 */ export function updateVideoTexture( gl: WebGL2RenderingContext, texture: WebGLTexture, video: HTMLVideoElement ) { if (video.readyState < video.HAVE_CURRENT_DATA) return; let ratio = video.videoWidth / video.videoHeight; if (isNaN(ratio)) { ratio = 1; } gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // 可选:取决于你 shader 中纹理坐标是否上下颠倒 gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, video.videoWidth, video.videoHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, video ); gl.generateMipmap(gl.TEXTURE_2D); return { ratio: ratio, } } ================================================ FILE: src/utils/GPUUtils.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { IMultiPassRenderer, RenderPassConfig } from './RendererInterface'; // ---- GPU Framebuffer ---- class GPUFrameBuffer { private device: GPUDevice; private _colorTexture: GPUTexture; private _colorView: GPUTextureView; private _depthTexture: GPUTexture; private _depthView: GPUTextureView; private width: number; private height: number; constructor(device: GPUDevice, width: number, height: number) { this.device = device; this.width = width; this.height = height; const { color, depth } = this.createTextures(); this._colorTexture = color; this._colorView = color.createView(); this._depthTexture = depth; this._depthView = depth.createView(); } private createTextures() { const color = this.device.createTexture({ size: [this.width, this.height], format: 'rgba16float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, }); const depth = this.device.createTexture({ size: [this.width, this.height], format: 'depth24plus', usage: GPUTextureUsage.RENDER_ATTACHMENT, }); return { color, depth }; } get colorTexture(): GPUTexture { return this._colorTexture; } get colorView(): GPUTextureView { return this._colorView; } get depthView(): GPUTextureView { return this._depthView; } resize(width: number, height: number): void { if (this.width === width && this.height === height) return; this._colorTexture.destroy(); this._depthTexture.destroy(); this.width = width; this.height = height; const { color, depth } = this.createTextures(); this._colorTexture = color; this._colorView = color.createView(); this._depthTexture = depth; this._depthView = depth.createView(); } dispose(): void { this._colorTexture.destroy(); this._depthTexture.destroy(); } } // ---- Render Pass Types ---- /** Pass type determines which bind group layout to use */ type PassType = 'bg' | 'blur' | 'main'; function detectPassType(config: RenderPassConfig): PassType { if (config.name === 'bgPass') return 'bg'; if (config.name === 'vBlurPass' || config.name === 'hBlurPass') return 'blur'; return 'main'; } // ---- GPU Render Pass ---- class GPURenderPassObj { private device: GPUDevice; private pipeline: GPURenderPipeline; private vertexBuffer: GPUBuffer; private frameBuffer: GPUFrameBuffer | null; private passType: PassType; public config: RenderPassConfig; // Bind group layout for dynamic recreation private bindGroupLayout: GPUBindGroupLayout; constructor( device: GPUDevice, config: RenderPassConfig, canvasFormat: GPUTextureFormat, width: number, height: number, ) { this.device = device; this.config = config; this.passType = detectPassType(config); // Create vertex buffer (fullscreen quad) const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); this.vertexBuffer = device.createBuffer({ size: vertices.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); device.queue.writeBuffer(this.vertexBuffer, 0, vertices); // Create bind group layout based on pass type this.bindGroupLayout = this.createBindGroupLayout(); // Create shader module (combined vertex + fragment) const shaderModule = device.createShaderModule({ code: config.shader.vertex + '\n' + config.shader.fragment, }); const outputFormat = config.outputToScreen ? canvasFormat : 'rgba16float' as GPUTextureFormat; this.pipeline = device.createRenderPipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout], }), vertex: { module: shaderModule, entryPoint: 'vs_main', buffers: [{ arrayStride: 2 * 4, // 2 floats * 4 bytes attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }], }], }, fragment: { module: shaderModule, entryPoint: 'fs_main', targets: [{ format: outputFormat }], }, primitive: { topology: 'triangle-strip' }, }); this.frameBuffer = config.outputToScreen ? null : new GPUFrameBuffer(device, width, height); } private createBindGroupLayout(): GPUBindGroupLayout { if (this.passType === 'blur') { return this.device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'read-only-storage' } }, ], }); } else if (this.passType === 'main') { return this.device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, ], }); } else { // bg pass return this.device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, ], }); } } render( encoder: GPUCommandEncoder, targetView: GPUTextureView | null, bindGroup: GPUBindGroup, ): void { const view = this.frameBuffer ? this.frameBuffer.colorView : targetView!; const passDesc: GPURenderPassDescriptor = { colorAttachments: [{ view, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: 'clear', storeOp: 'store', }], }; const pass = encoder.beginRenderPass(passDesc); pass.setPipeline(this.pipeline); pass.setBindGroup(0, bindGroup); pass.setVertexBuffer(0, this.vertexBuffer); pass.draw(4); pass.end(); } getOutputTexture(): GPUTexture | null { return this.frameBuffer ? this.frameBuffer.colorTexture : null; } getBindGroupLayout(): GPUBindGroupLayout { return this.bindGroupLayout; } getPassType(): PassType { return this.passType; } resize(width: number, height: number): void { this.frameBuffer?.resize(width, height); } dispose(): void { this.frameBuffer?.dispose(); this.vertexBuffer.destroy(); } } // ---- Multi-Pass Renderer ---- export class GPUMultiPassRenderer implements IMultiPassRenderer { private device: GPUDevice; private context: GPUCanvasContext; private canvasFormat: GPUTextureFormat; private passes: Map = new Map(); private passesArray: GPURenderPassObj[] = []; private globalUniforms: Record = {}; private sampler: GPUSampler; // Shared uniform buffer (re-created each frame) private uniformBuffer: GPUBuffer | null = null; // Blur weights storage buffer private blurWeightsBuffer: GPUBuffer | null = null; // Placeholder 1x1 white texture for when no bg texture is available private placeholderTexture: GPUTexture; constructor( canvas: HTMLCanvasElement, configs: RenderPassConfig[], device: GPUDevice, ) { this.device = device; const context = canvas.getContext('webgpu'); if (!context) throw new Error('WebGPU context not available'); this.context = context; this.canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format: this.canvasFormat, alphaMode: 'opaque', }); this.sampler = device.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear', addressModeU: 'clamp-to-edge', addressModeV: 'clamp-to-edge', }); // Placeholder 1x1 white texture this.placeholderTexture = device.createTexture({ size: [1, 1], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, }); device.queue.writeTexture( { texture: this.placeholderTexture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1], ); for (const cfg of configs) { const pass = new GPURenderPassObj(device, cfg, this.canvasFormat, canvas.width, canvas.height); this.passes.set(cfg.name, pass); this.passesArray.push(pass); } } resize(width: number, height: number): void { for (const pass of this.passesArray) { pass.resize(width, height); } } setUniform(name: string, value: any): void { this.globalUniforms[name] = value; } setUniforms(uniforms: Record): void { Object.assign(this.globalUniforms, uniforms); } clearUniform(name: string): void { delete this.globalUniforms[name]; } clearAllUniforms(): void { this.globalUniforms = {}; } render(passUniforms?: Record[] | Record>): void { const encoder = this.device.createCommandEncoder(); const targetView = this.context.getCurrentTexture().createView(); for (let i = 0; i < this.passesArray.length; i++) { const pass = this.passesArray[i]; const config = pass.config; // Merge global + per-pass uniforms const uniforms: Record = { ...this.globalUniforms }; if (passUniforms) { if (Array.isArray(passUniforms)) { Object.assign(uniforms, passUniforms[i]); } else { Object.assign(uniforms, passUniforms[config.name] ?? null); } } // Resolve input textures from previous passes const inputTextures: Record = {}; if (config.inputs) { for (const [uniformName, fromPassName] of Object.entries(config.inputs)) { const fromPass = this.passes.get(fromPassName); const tex = fromPass?.getOutputTexture(); if (tex) { inputTextures[uniformName] = tex; } } } // Build bind group for this pass const bindGroup = this.buildBindGroup(pass, uniforms, inputTextures); pass.render( encoder, config.outputToScreen ? targetView : null, bindGroup, ); } this.device.queue.submit([encoder.finish()]); } private buildBindGroup( pass: GPURenderPassObj, uniforms: Record, inputTextures: Record, ): GPUBindGroup { const passType = pass.getPassType(); if (passType === 'blur') { return this.buildBlurBindGroup(pass, uniforms, inputTextures); } else if (passType === 'main') { return this.buildMainBindGroup(pass, uniforms, inputTextures); } else { return this.buildBgBindGroup(pass, uniforms); } } private buildBgBindGroup(pass: GPURenderPassObj, uniforms: Record): GPUBindGroup { const uniformBuffer = this.createMainUniformBuffer(uniforms); const bgTexture = (uniforms.u_bgTexture as GPUTexture) ?? this.placeholderTexture; return this.device.createBindGroup({ layout: pass.getBindGroupLayout(), entries: [ { binding: 0, resource: { buffer: uniformBuffer } }, { binding: 1, resource: bgTexture.createView() }, { binding: 2, resource: this.sampler }, ], }); } private buildBlurBindGroup( pass: GPURenderPassObj, uniforms: Record, inputTextures: Record, ): GPUBindGroup { // Blur uniform buffer: resolution(vec2f) + blurRadius(i32) + pad(i32) = 16 bytes const data = new ArrayBuffer(16); const f32 = new Float32Array(data); const i32 = new Int32Array(data); f32[0] = uniforms.u_resolution?.[0] ?? 0; f32[1] = uniforms.u_resolution?.[1] ?? 0; i32[2] = uniforms.u_blurRadius ?? 1; i32[3] = 0; // pad const uniformBuffer = this.device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); this.device.queue.writeBuffer(uniformBuffer, 0, data); // Blur weights storage buffer const weights: number[] = uniforms.u_blurWeights ?? [1.0]; const weightsData = new Float32Array(Math.max(weights.length, 4)); // minimum 16 bytes weightsData.set(weights); const weightsBuffer = this.device.createBuffer({ size: weightsData.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, }); this.device.queue.writeBuffer(weightsBuffer, 0, weightsData); // Input texture const inputTex = inputTextures.u_prevPassTexture ?? this.placeholderTexture; return this.device.createBindGroup({ layout: pass.getBindGroupLayout(), entries: [ { binding: 0, resource: { buffer: uniformBuffer } }, { binding: 1, resource: inputTex.createView() }, { binding: 2, resource: this.sampler }, { binding: 3, resource: { buffer: weightsBuffer } }, ], }); } private buildMainBindGroup( pass: GPURenderPassObj, uniforms: Record, inputTextures: Record, ): GPUBindGroup { const uniformBuffer = this.createMainUniformBuffer(uniforms); const blurredBg = inputTextures.u_blurredBg ?? this.placeholderTexture; const bg = inputTextures.u_bg ?? this.placeholderTexture; return this.device.createBindGroup({ layout: pass.getBindGroupLayout(), entries: [ { binding: 0, resource: { buffer: uniformBuffer } }, { binding: 1, resource: blurredBg.createView() }, { binding: 2, resource: bg.createView() }, { binding: 3, resource: this.sampler }, ], }); } /** * Creates the main uniform buffer matching the Uniforms struct layout in WGSL. * Layout (std140-like alignment): * * offset field type size * 0 u_resolution vec2f 8 * 8 u_dpr f32 4 * 12 _pad0 f32 4 * 16 u_mouse vec2f 8 * 24 u_mouseSpring vec2f 8 * 32 u_shapeWidth f32 4 * 36 u_shapeHeight f32 4 * 40 u_shapeRadius f32 4 * 44 u_shapeRoundness f32 4 * 48 u_mergeRate f32 4 * 52 u_glareAngle f32 4 * 56 u_shadowExpand f32 4 * 60 u_shadowFactor f32 4 * 64 u_shadowPosition vec2f 8 * 72 u_bgTextureRatio f32 4 * 76 u_bgType i32 4 * 80 u_bgTextureReady i32 4 * 84 u_showShape1 i32 4 * 88 u_blurRadius i32 4 * 92 u_blurEdge i32 4 * 96 u_tint vec4f 16 * 112 u_refThickness f32 4 * 116 u_refFactor f32 4 * 120 u_refDispersion f32 4 * 124 u_refFresnelRange f32 4 * 128 u_refFresnelHardness f32 4 * 132 u_refFresnelFactor f32 4 * 136 u_glareRange f32 4 * 140 u_glareHardness f32 4 * 144 u_glareConvergence f32 4 * 148 u_glareOppositeFactor f32 4 * 152 u_glareFactor f32 4 * 156 _pad1 f32 4 * Total: 160 bytes */ private createMainUniformBuffer(uniforms: Record): GPUBuffer { const BUFFER_SIZE = 160; const data = new ArrayBuffer(BUFFER_SIZE); const f32 = new Float32Array(data); const i32 = new Int32Array(data); // u_resolution (offset 0) const res = uniforms.u_resolution ?? [0, 0]; f32[0] = res[0]; f32[1] = res[1]; // u_dpr (offset 8) f32[2] = uniforms.u_dpr ?? 1; // _pad0 (offset 12) f32[3] = 0; // u_mouse (offset 16) const mouse = uniforms.u_mouse ?? [0, 0]; f32[4] = mouse[0]; f32[5] = mouse[1]; // u_mouseSpring (offset 24) const ms = uniforms.u_mouseSpring ?? [0, 0]; f32[6] = ms[0]; f32[7] = ms[1]; // u_shapeWidth (offset 32) f32[8] = uniforms.u_shapeWidth ?? 200; // u_shapeHeight (offset 36) f32[9] = uniforms.u_shapeHeight ?? 200; // u_shapeRadius (offset 40) f32[10] = uniforms.u_shapeRadius ?? 80; // u_shapeRoundness (offset 44) f32[11] = uniforms.u_shapeRoundness ?? 5; // u_mergeRate (offset 48) f32[12] = uniforms.u_mergeRate ?? 0.05; // u_glareAngle (offset 52) f32[13] = uniforms.u_glareAngle ?? 0; // u_shadowExpand (offset 56) f32[14] = uniforms.u_shadowExpand ?? 25; // u_shadowFactor (offset 60) f32[15] = uniforms.u_shadowFactor ?? 0.15; // u_shadowPosition (offset 64) const sp = uniforms.u_shadowPosition ?? [0, 0]; f32[16] = sp[0]; f32[17] = sp[1]; // u_bgTextureRatio (offset 72) f32[18] = uniforms.u_bgTextureRatio ?? 1; // u_bgType (offset 76) i32[19] = uniforms.u_bgType ?? 0; // u_bgTextureReady (offset 80) i32[20] = uniforms.u_bgTextureReady ?? 0; // u_showShape1 (offset 84) i32[21] = uniforms.u_showShape1 ?? 1; // u_blurRadius (offset 88) i32[22] = uniforms.u_blurRadius ?? 1; // u_blurEdge (offset 92) i32[23] = uniforms.u_blurEdge ?? 1; // u_tint (offset 96, vec4f aligned to 16 bytes) const tint = uniforms.u_tint ?? [1, 1, 1, 0]; f32[24] = tint[0]; f32[25] = tint[1]; f32[26] = tint[2]; f32[27] = tint[3]; // u_refThickness (offset 112) f32[28] = uniforms.u_refThickness ?? 20; // u_refFactor (offset 116) f32[29] = uniforms.u_refFactor ?? 1.4; // u_refDispersion (offset 120) f32[30] = uniforms.u_refDispersion ?? 7; // u_refFresnelRange (offset 124) f32[31] = uniforms.u_refFresnelRange ?? 30; // u_refFresnelHardness (offset 128) f32[32] = uniforms.u_refFresnelHardness ?? 0.2; // u_refFresnelFactor (offset 132) f32[33] = uniforms.u_refFresnelFactor ?? 0.2; // u_glareRange (offset 136) f32[34] = uniforms.u_glareRange ?? 30; // u_glareHardness (offset 140) f32[35] = uniforms.u_glareHardness ?? 0.2; // u_glareConvergence (offset 144) f32[36] = uniforms.u_glareConvergence ?? 0.5; // u_glareOppositeFactor (offset 148) f32[37] = uniforms.u_glareOppositeFactor ?? 0.8; // u_glareFactor (offset 152) f32[38] = uniforms.u_glareFactor ?? 0.9; // _pad1 (offset 156) f32[39] = 0; const buffer = this.device.createBuffer({ size: BUFFER_SIZE, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); this.device.queue.writeBuffer(buffer, 0, data); return buffer; } dispose(): void { for (const pass of this.passesArray) { pass.dispose(); } this.passes.clear(); this.passesArray = []; this.uniformBuffer?.destroy(); this.blurWeightsBuffer?.destroy(); this.placeholderTexture.destroy(); this.globalUniforms = {}; } } // ---- Texture Utilities ---- export async function gpuLoadTextureFromURL( device: GPUDevice, url: string, ): Promise<{ texture: GPUTexture; ratio: number }> { // Load via Image element first — this handles SVG and all browser-supported // image formats, matching WebGL's loadTextureFromURL behavior. const img = new Image(); img.crossOrigin = ''; await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = reject; img.src = url; }); const bitmap = await createImageBitmap(img); const w = bitmap.width; const h = bitmap.height; const texture = device.createTexture({ size: [w, h], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, }); device.queue.copyExternalImageToTexture( { source: bitmap, flipY: false }, { texture }, [w, h], ); bitmap.close(); return { texture, ratio: w / h }; } export function gpuCreateEmptyTexture(device: GPUDevice, width = 1, height = 1): GPUTexture { return device.createTexture({ size: [width, height], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, }); } export async function gpuUpdateVideoTexture( device: GPUDevice, texture: GPUTexture, video: HTMLVideoElement, ): Promise<{ ratio: number; texture: GPUTexture } | undefined> { if (video.readyState < video.HAVE_CURRENT_DATA) return; let ratio = video.videoWidth / video.videoHeight; if (isNaN(ratio)) ratio = 1; // Recreate texture if video size changed let oldTexture: GPUTexture | null = null; if (texture.width !== video.videoWidth || texture.height !== video.videoHeight) { oldTexture = texture; texture = device.createTexture({ size: [video.videoWidth, video.videoHeight], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, }); } // Use createImageBitmap to ensure consistent sRGB color handling (same as image path) const bitmap = await createImageBitmap(video); device.queue.copyExternalImageToTexture( { source: bitmap, flipY: false }, { texture }, [bitmap.width, bitmap.height], ); bitmap.close(); // Destroy old texture after the new one is ready and written to if (oldTexture) { oldTexture.destroy(); } return { ratio, texture }; } ================================================ FILE: src/utils/RendererInterface.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ /** * Common render pass configuration shared by both WebGL2 and WebGPU backends. */ export interface RenderPassConfig { name: string; shader: { vertex: string; fragment: string; }; inputs?: { [uniformName: string]: string }; outputToScreen?: boolean; } /** * Opaque texture handle that wraps backend-specific texture objects. * WebGL2 backend stores WebGLTexture, WebGPU backend stores GPUTexture. */ export type ITextureHandle = WebGLTexture | GPUTexture; /** * Common interface for multi-pass renderers. * Both WebGL2 MultiPassRenderer and WebGPU GPUMultiPassRenderer implement this. */ export interface IMultiPassRenderer { resize(width: number, height: number): void; setUniform(name: string, value: any): void; setUniforms(uniforms: Record): void; clearUniform(name: string): void; clearAllUniforms(): void; render(passUniforms?: Record[] | Record>): void; dispose(): void; } /** * Return type for texture loading functions. */ export interface TextureLoadResult { texture: T; ratio: number; } ================================================ FILE: src/utils/gpuDetect.ts ================================================ /** * Detect WebGPU support in the current browser environment. */ export interface WebGPUDetectResult { supported: boolean; reason?: string; adapter?: GPUAdapter; device?: GPUDevice; } let cachedResult: WebGPUDetectResult | null = null; let detectPromise: Promise | null = null; export async function detectWebGPU(): Promise { if (cachedResult) return cachedResult; if (detectPromise) return detectPromise; detectPromise = (async () => { if (!navigator.gpu) { cachedResult = { supported: false, reason: 'WebGPU API not available' }; return cachedResult; } try { const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { cachedResult = { supported: false, reason: 'No GPU adapter found' }; return cachedResult; } const device = await adapter.requestDevice(); if (!device) { cachedResult = { supported: false, reason: 'Failed to get GPU device' }; return cachedResult; } cachedResult = { supported: true, adapter, device }; return cachedResult; } catch (e) { cachedResult = { supported: false, reason: e instanceof Error ? e.message : 'Unknown WebGPU error', }; return cachedResult; } })(); return detectPromise; } /** * Returns the cached detection result, or null if detection hasn't completed yet. */ export function getWebGPUDetectResult(): WebGPUDetectResult | null { return cachedResult; } ================================================ FILE: src/utils/index.ts ================================================ export function computeGaussianKernelByRadius(radius: number) { const sigma = radius / 3.0; const kernel = []; let sum = 0; for (let i = 0; i <= radius; i++) { const weight = Math.exp(-0.5 * (i * i) / (sigma * sigma)); kernel.push(weight); sum += i === 0 ? weight : weight * 2; } return kernel.map(w => w / sum); // 归一化 } export function isChineseLanguage() { return navigator.language.startsWith('zh'); } export function isUzbekLanguage() { return navigator.language.startsWith('uz'); } export function capitalize(str: string) { return `${str.charAt(0).toUpperCase()}${str.slice(1)}`; } ================================================ FILE: src/utils/languages.ts ================================================ export default { ['zh-CN']: { '_settings': { rootWidth: '350px', numberInputMinWidth: '42px', controlWidth: '215px', }, 'ui.subtitle': '液态玻璃效果模拟工具(基于WebGL & WebGPU)', 'editor.basicSettings': '基础设置', 'editor.language': '语言', 'editor.refThickness': '折射厚度', 'editor.refFactor': '折射系数', 'editor.refDispersion': '色散增益', 'editor.refFresnelRange': '菲涅尔反射范围', 'editor.refFresnelHardness': '菲涅尔反射硬度', 'editor.refFresnelFactor': '菲涅尔反射强度', 'editor.glareRange': '高光范围', 'editor.glareHardness': '高光硬度', 'editor.glareConvergence': '高光聚拢度', 'editor.glareOppositeFactor': '高光对侧强度', 'editor.glareFactor': '高光强度', 'editor.glareAngle': '高光角度', 'editor.blurRadius': '模糊半径', 'editor.blurEdge': '模糊边缘', 'editor.tint': '色调', 'editor.shadowExpand': '阴影扩散', 'editor.shadowFactor': '阴影强度', 'editor.shadowPosition': '阴影位置', 'editor.bgType': '背景', 'editor.shapeSettings': '形状设置', 'editor.shapeWidth': '宽', 'editor.shapeHeight': '高', 'editor.shapeRadius': '圆角 (%)', 'editor.shapeRoundness': '超椭圆系数', 'editor.mergeRate': '形状融合度', 'editor.showShape1': '显示第二个图形', 'editor.animationSettings': '动画设置', 'editor.springSizeFactor': '动画形变', 'editor.debugSettings': '调试', 'editor.export': '导出预设', 'editor.import': '导入预设', 'editor.importSuccessMessage': '预设导入成功!', 'editor.importFailedMessage': (message: string) => `导入失败:${message}`, 'editor.renderer': '渲染引擎', 'editor.rendererWebGPUUnavailable': 'WebGPU 不可用', }, ['en-US']: { '_settings': { rootWidth: '400px', numberInputMinWidth: '42px', controlWidth: '215px', }, 'ui.subtitle': 'Liquid Glass Effect Simulation Tool (powered by WebGL & WebGPU)', 'editor.basicSettings': 'Basic Settings', 'editor.language': 'Language', 'editor.refThickness': 'Thickness', 'editor.refFactor': 'Refraction Factor', 'editor.refDispersion': 'Dispersion Gain', 'editor.refFresnelRange': 'Fresnel Size', 'editor.refFresnelHardness': 'Fresnel Hardness', 'editor.refFresnelFactor': 'Fresnel Intensity', 'editor.glareRange': 'Glare Size', 'editor.glareHardness': 'Glare Hardness', 'editor.glareConvergence': 'Glare Convergence', 'editor.glareOppositeFactor': 'Glare Opposite Side', 'editor.glareFactor': 'Glare Intensity', 'editor.glareAngle': 'Glare Angle', 'editor.blurRadius': 'Blur Radius', 'editor.blurEdge': 'Blur Edge', 'editor.tint': 'Tint', 'editor.shadowExpand': 'Shadow Expand', 'editor.shadowFactor': 'Shadow Intensity', 'editor.shadowPosition': 'Shadow Position', 'editor.bgType': 'Background', 'editor.shapeSettings': 'Shape Settings', 'editor.shapeWidth': 'Width', 'editor.shapeHeight': 'Height', 'editor.shapeRadius': 'Radius (%)', 'editor.shapeRoundness': 'SuperEllipse Factor', 'editor.mergeRate': 'Merge Rate', 'editor.showShape1': 'Show 2nd Shape', 'editor.animationSettings': 'Animation Settings', 'editor.springSizeFactor': 'Animation Morph', 'editor.debugSettings': 'Debug', 'editor.export': 'Export Preset', 'editor.import': 'Import Preset', 'editor.importSuccessMessage': 'Preset imported successfully!', 'editor.importFailedMessage': (message: string) => `Import failed: ${message}`, 'editor.renderer': 'Renderer', 'editor.rendererWebGPUUnavailable': 'WebGPU not available', }, ['uz-UZ']: { '_settings': { "rootWidth": "400px", "numberInputMinWidth": "42px", "controlWidth": "215px" }, "ui.subtitle": "Liquid Glass effekt simulyatori (WebGL & WebGPU asosida)", "editor.basicSettings": "Asosiy sozlamalar", "editor.language": "Til", "editor.refThickness": "Qalinlik", "editor.refFactor": "Sinish koeffitsiyenti", "editor.refDispersion": "Dispersiya darajasi", "editor.refFresnelRange": "Fresnel o‘lchami", "editor.refFresnelHardness": "Fresnel qattiqligi", "editor.refFresnelFactor": "Fresnel intensivligi", "editor.glareRange": "Yaltiroq maydoni", "editor.glareHardness": "Yaltiroq qattiqligi", "editor.glareConvergence": "Yaltiroq to‘planishi", "editor.glareOppositeFactor": "Qarama-qarshi tomon yorqinligi", "editor.glareFactor": "Yaltiroq intensivligi", "editor.glareAngle": "Yaltiroq burchagi", "editor.blurRadius": "Xiralashish radiusi", "editor.blurEdge": "Xiralashish qirralar", "editor.tint": "Rang ohangi", "editor.shadowExpand": "Soya kengligi", "editor.shadowFactor": "Soya kuchi", "editor.shadowPosition": "Soya joylashuvi", "editor.bgType": "Fon turi", "editor.shapeSettings": "Shakl sozlamalari", "editor.shapeWidth": "Kenglik", "editor.shapeHeight": "Balandlik", "editor.shapeRadius": "Radius (%)", "editor.shapeRoundness": "Burchak yumaloqligi", "editor.mergeRate": "Qo‘shilish tezligi", "editor.showShape1": "2-shaklni ko‘rsatish", "editor.animationSettings": "Animatsiya sozlamalari", "editor.springSizeFactor": "Animatsiya deformatsiyasi", "editor.debugSettings": "Debug", "editor.export": "Presetni eksport qilish", "editor.import": "Presetni import qilish", "editor.importSuccessMessage": "Preset muvaffaqiyatli import qilindi!", "editor.importFailedMessage": (message: string) => `Import failed: ${message}`, "editor.renderer": "Renderer", "editor.rendererWebGPUUnavailable": "WebGPU mavjud emas", } } ================================================ FILE: src/utils/presetUtils.ts ================================================ import { type useLevaControls } from '../Controls'; export interface PresetData { version: string; timestamp: string; controls: ReturnType['controls']; } export function exportPreset( controls: ReturnType['controls'], filename: string = 'liquid-glass-preset.json', ): void { const preset: PresetData = { version: '1.0.0', timestamp: new Date().toISOString(), controls: structuredClone(controls), }; const jsonStr = JSON.stringify(preset, null, 2); const blob = new Blob([jsonStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } export function importPreset(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { try { const content = e.target?.result as string; const preset = JSON.parse(content) as PresetData; if (!preset.version || !preset.controls) { reject(new Error('Invalid preset file format')); return; } resolve(preset); } catch (err) { reject(new Error(`Failed to parse preset file: ${err}`)); } }; reader.onerror = () => { reject(new Error('Failed to read file')); }; reader.readAsText(file); }); } ================================================ FILE: src/utils/useResizeOberver.ts ================================================ type ObserveCb = (rect: DOMRect, target: HTMLElement) => void; // Global observer let resizeObserver: ResizeObserver | null = null; const observed = new WeakMap(); const onResize: ResizeObserverCallback = (entries) => { entries.forEach((entry) => { const info = observed.get(entry.target); if (info) { const cbList = info; cbList.forEach((cb) => { cb(entry.contentRect as DOMRect, entry.target as HTMLElement); }); } }); }; const unobserve = (el: HTMLElement, cb?: ObserveCb) => { if (!observed.has(el) || !resizeObserver) { return; } if (!cb) { observed.delete(el); resizeObserver.unobserve(el); return; } const cbList = observed.get(el)!; const cbIdx = cbList.indexOf(cb); if (cbIdx > -1) { cbList.splice(cbIdx, 1); } if (!cbList.length) { observed.delete(el); resizeObserver.unobserve(el); } }; const observe = (el: HTMLElement, cb: ObserveCb) => { if (!resizeObserver) { resizeObserver = new ResizeObserver(onResize); } if (!observed.has(el)) { observed.set(el, []); resizeObserver.observe(el); } const cbList = observed.get(el)!; if (!cbList.includes(cb)) { cbList.push(cb); } return () => { unobserve(el, cb); }; }; export function useResizeObserver() { return { observe, unobserve, }; } ================================================ FILE: src/vite-env.d.ts ================================================ /// ================================================ FILE: tsconfig.app.json ================================================ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, // "noUnusedLocals": true, // "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, "types": ["@webgpu/types"], "baseUrl": ".", "paths": { "@": ["src"], "@/*": ["src/*"] } }, "include": ["src"] } ================================================ FILE: tsconfig.json ================================================ { "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ] } ================================================ FILE: tsconfig.node.json ================================================ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2022", "lib": ["ES2023"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: vite.config.ts ================================================ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' import tsconfigPaths from 'vite-tsconfig-paths' // https://vite.dev/config/ export default defineConfig({ server: { host: '0.0.0.0', allowedHosts: true, }, plugins: [react(), tsconfigPaths()], });