Showing preview only (292K chars total). Download the full file or copy to clipboard to get everything.
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

[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
<table align="center">
<tr>
<td><img src="./.github/assets/title-video.gif" width="240" ></td>
<td><img src="./.github/assets/screen-shot-1.png" width="240" /></td>
<td><img src="./.github/assets/screen-shot-2.png" width="240" /></td>
</tr>
<tr>
<td><img src="./.github/assets/screen-shot-3.png" width="240" /></td>
<td><img src="./.github/assets/screen-shot-4.png" width="240" /></td>
</tr>
</table>
## 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

[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/
## 截图预览
<table align="center">
<tr>
<td><img src="./.github/assets/title-video.gif" width="240" ></td>
<td><img src="./.github/assets/screen-shot-1.png" width="240" /></td>
<td><img src="./.github/assets/screen-shot-2.png" width="240" /></td>
</tr>
<tr>
<td><img src="./.github/assets/screen-shot-3.png" width="240" /></td>
<td><img src="./.github/assets/screen-shot-4.png" width="240" /></td>
</tr>
</table>
## 功能特性
**✨ 完整复现 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

[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
<table align="center">
<tr>
<td><img src="./.github/assets/title-video.gif" width="240" ></td>
<td><img src="./.github/assets/screen-shot-1.png" width="240" /></td>
<td><img src="./.github/assets/screen-shot-2.png" width="240" /></td>
</tr>
<tr>
<td><img src="./.github/assets/screen-shot-3.png" width="240" /></td>
<td><img src="./.github/assets/screen-shot-4.png" width="240" /></td>
</tr>
</table>
## 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 <a href="https://unsplash.com/@anewevisual?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Adrian Newell</a> on <a href="https://unsplash.com/photos/a-row-of-multicolored-houses-on-a-street-UtfxJZ-uy5Q?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
- 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
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Liquid Glass Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
================================================
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 `<canvas>` 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-<timestamp>.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-<timestamp>.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<HTMLCanvasElement>(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<WebGPUDetectResult | null>(null);
const [rendererBackend, setRendererBackend] = useState<'webgl' | 'webgpu'>('webgl');
// Incrementing key forces React to remount the <canvas>, 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 | 'image' | 'video'>(null);
const [customFile, setCustomFile] = useState<null | File>(null);
const [customFileUrl, setCustomFileUrl] = useState<null | string>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className={styles.bgSelect}>
{[
{ 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 (
<div
className={clsx(
styles.bgSelectItem,
styles[`bgSelectItemType${capitalize(type ?? 'image')}`],
{
[styles.bgSelectItemActive]: value === v,
},
)}
// style={{ backgroundImage: !type ? `url(${media})` : '' }}
key={v}
onClick={() => {
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' ? (
<video
playsInline
muted={true}
loop
className={styles.bgSelectItemVideo}
ref={(ref) => {
if (ref) {
stateRef.current.bgVideoEls.set(v, ref);
} else {
stateRef.current.bgVideoEls.delete(v);
}
}}
>
<source src={mediaUrl}></source>
</video>
) : mediaType === 'image' ? (
<img src={mediaUrl} className={styles.bgSelectItemImg} />
) : null)}
{type === 'custom' ? (
<>
<input
type="file"
accept="image/*,video/*"
ref={fileInputRef}
multiple={false}
onChange={(e) => {
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';
}
}}
></input>
<FileUploadOutlinedIcon />
</>
) : null}
<div
className={clsx(
styles.bgSelectItemOverlay,
styles[`bgSelectItemOverlay${capitalize(type ?? 'image')}`],
)}
>
{mediaType === 'video' && (
<PlayCircleOutlinedIcon
className={styles.bgSelectItemVideoIcon}
style={{
opacity: value !== v ? 1 : 0,
}}
/>
)}
{type === 'custom' && (
<div className={styles.bgSelectItemCustomIcon}>
<FileUploadOutlinedIcon />
</div>
)}
</div>
</div>
);
})}
</div>
);
},
/* eslint-enable react-hooks/rules-of-hooks */
},
});
const stateRef = useRef<{
canvasWindowCtrlRef: ResizeWindowCtrlRefType | null;
renderRaf: number | null;
canvasInfo: typeof canvasInfo;
glStates: {
gl: WebGL2RenderingContext;
programs: Record<string, WebGLProgram>;
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<number, HTMLVideoElement>;
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 <canvas>
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}
<header className={styles.header}>
<div className={styles.logoWrapper}>
<div className={styles.title}>Liquid Glass Studio</div>
<div className={styles.subtitle}>{lang['ui.subtitle']}</div>
</div>
<div className={styles.content}>
<span>
by <a>iyinchao</a>
</span>
<a
href="https://github.com/iyinchao/liquid-glass-studio"
target="_blank"
className={styles.button}
>
<GitHubIcon />
</a>
<a
href="https://x.com/charles_yin/status/1936338569267986605"
target="_blank"
className={styles.button}
>
<XIcon></XIcon>
</a>
</div>
</header>
<PresetControls
controls={controls}
controlsAPI={controlsAPI}
lang={lang}
/>
<ResizableWindow
disableMove
size={canvasInfo}
onResize={(size) => {
setCanvasInfo({
...size,
dpr: window.devicePixelRatio,
});
centerizeCanvasWindow();
}}
onMove={(pos) => {
stateRef.current.canvasPos = pos;
}}
ctrlRef={(ref) => {
stateRef.current.canvasWindowCtrlRef = ref;
}}
>
<div className={clsx(styles.canvasContainer)}>
<canvas
key={canvasKey}
ref={canvasRef}
className={styles.canvas}
style={
{
['--dpr']: canvasInfo.dpr,
} as CSSProperties
}
/>
</div>
</ResizableWindow>
</>
);
}
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<keyof typeof languages>(
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<typeof lang, '_settings'>] ?? titleStr}"`,
);
ctrlEl.dataset.levaFolder = '1';
}
});
}, 0);
}, [lang]);
const levaGlobal = (
<Leva
theme={{
sizes: {
rootWidth: lang['_settings'].rootWidth,
numberInputMinWidth: lang['_settings'].numberInputMinWidth,
controlWidth: lang['_settings'].controlWidth,
},
space: {
colGap: '5px',
},
}}
></Leva>
);
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<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
export const LevaButton = ({ children, className, intent = 'normal', active, ...rest }: LevaButtonProps) => {
return (
<button
{...rest}
className={clsx('leva-button-custom', `leva-button-custom--intent-${intent}`, {
[`leva-button-custom--active`]: active,
}, className)}
>
<div style={{ display: 'flex', alignItems: 'center', paddingTop: 0 }}>{children}</div>
</button>
);
};
================================================
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<CheckButtonsValueType, CheckButtonsSettings, string>;
function LevaCheckButtonsComponent() {
const props = useInputContext<CheckButtonsLevaProps>();
const { label, displayValue, onUpdate, onChange, settings, value } = props;
const settingsRequired = settings as Required<CheckButtonsSettings>;
const { options, singleMode } = settingsRequired;
return (
<Row input className={'leva-check-buttons'}>
<Label>{label}</Label>
<div className="leva-check-buttons__button-group">
{options.map((option) => {
const selectedIdx = value.indexOf(option.value);
const isSelected = selectedIdx >= 0;
return (
<LevaButton
// active={isSelected}
intent={isSelected ? 'primary' : 'normal'}
disabled={option.disabled}
title={option.title}
key={option.value}
onClick={() => {
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}
</LevaButton>
);
})}
</div>
</Row>
);
}
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<ContainerValueType, ContainerSettings, string>;
function ContainerComponent() {
const props = useInputContext<ContainerInputProps>();
const { label, displayValue, onUpdate, onChange, settings, value } = props;
return (
<Row
className={clsx(
'leva-container',
{
['leva-container--with-label']: settings.showLabel,
},
`leva-container--label-vertical-align-${settings.labelVerticalAlign}`,
settings.className,
)}
style={
{
...settings.style,
'--label-width':
typeof settings.labelWidth === 'number'
? `${settings.labelWidth}px`
: settings.labelWidth,
'--content-width': settings.contentWidth,
} as CSSProperties
}
input={settings.showLabel && !settings.labelRow}
>
{settings.showLabel ? <Label>{label}</Label> : null}
<div
data-label={displayValue}
className={settings.contentWrapperClassName}
style={settings.contentWrapperStyle}
>
{typeof settings.content === 'function'
? settings.content?.({
value,
setValue: (v) => {
onUpdate(v);
},
})
: settings.content}
</div>
</Row>
);
}
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<ImageUploadValueType, ImageUploadSettings, string>;
function GreenOrBlue() {
const props = useInputContext<ImageUploadLevaProps>();
const { label, displayValue, onUpdate, onChange, settings, value, disabled } = props;
const stateRef = useRef<{
imageObjURL: null | string;
}>({
imageObjURL: null,
});
const inputRef = useRef<HTMLInputElement>(null);
const {
previewSize,
accept,
alphaPatternSize,
alphaPatternColorA,
alphaPatternColorB,
displayDisabled,
clearable,
} = settings;
const previewSizeNorm =
typeof previewSize === 'number'
? ([previewSize, previewSize] as const)
: (previewSize as [number, number]);
return (
<Row
input
className={clsx('leva-image-upload', {
'leva-image-upload--disabled': displayDisabled,
})}
>
<Label>{label}</Label>
<div
style={
{
'--preview-width': `${previewSizeNorm[0]}px`,
'--preview-height': `${previewSizeNorm[1]}px`,
'--alpha-pattern-size': `${alphaPatternSize}px`,
'--alpha-pattern-color-a': `${alphaPatternColorA}`,
'--alpha-pattern-color-b': `${alphaPatternColorB}`,
} as CSSProperties
}
>
<input
style={{ display: 'none' }}
disabled={disabled}
type="file"
data-label={displayValue}
accept={accept}
multiple={false}
ref={inputRef}
onChange={(e) => {
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);
}}
></input>
<div
className="leva-image-upload__preview"
onClick={() => {
if (!inputRef.current) {
return;
}
inputRef.current.click();
}}
>
{value?.file ? (
<img src={value?.file} className="leva-image-upload__preview-img"></img>
) : null}
{value?.file && clearable && <DeleteIcon className="leva-image-upload__clearbt" onClick={(e) => {
e.stopPropagation();
onUpdate({ file: undefined });
onChange({ file: undefined });
}}></DeleteIcon>}
</div>
</div>
</Row>
);
}
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<VectorNewValueType, VectorNewSettings, string>;
type JoyStickProps = {
showVectorLine?: boolean;
size?: number;
value: VectorNewValueType;
onUpdate: (value: VectorNewValueType) => void;
};
function Joystick({
showVectorLine = true,
size = 130,
onUpdate,
value,
settings,
}: JoyStickProps & {
settings: Omit<Required<VectorNewSettings>, keyof JoyStickProps>;
}) {
const [showPop, setShowPop] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rootRef = useRef<HTMLDivElement>(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 (
<div
className={clsx('leva-vector-new__joystick')}
onPointerDown={(e) => {
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 && (
<Portal>
<div
className={'leva-vector-new__joystick-pop'}
style={
{
'--root-center-x': `${rootCenter.x}px`,
'--root-center-y': `${rootCenter.y}px`,
'--joystick-size': `${size}px`,
} as CSSProperties
}
>
<div className="leva-vector-new__joystick-pop-bg"></div>
<canvas width={1} height={1} ref={canvasRef}></canvas>
<div className="leva-vector-new__joystick-pop-value">{`${settings.xLabel}: ${value.x}, ${settings.yLabel}: ${value.y}`}</div>
</div>
</Portal>
)}
</div>
);
}
function LevaVectorNewComponent() {
const props = useInputContext<VectorNewLevaProps>();
const { label, displayValue, onUpdate, onChange, settings, value } = props;
const settingsRequired = settings as Required<VectorNewSettings>;
const { step, showVectorLine, joystickSize } = settingsRequired;
return (
<Row input className={'leva-vector-new'}>
<Label>{label}</Label>
<div className="leva-vector-new__input-wrapper">
<Joystick
showVectorLine={showVectorLine}
size={joystickSize}
value={value}
onUpdate={onUpdate}
settings={settingsRequired}
></Joystick>
{(['x', 'y'] as const).map((k) => {
return (
<NumberComp
key={k}
displayValue={(displayValue as any)[k]}
value={(displayValue as any)[k]}
onUpdate={(v) => {
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`]!}
></NumberComp>
);
})}
</div>
</Row>
);
}
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<typeof useLevaControls>['controls'];
controlsAPI: ReturnType<typeof useLevaControls>['controlsAPI'];
lang: ReturnType<typeof useLevaControls>['lang'];
}
export const PresetControls = ({ controls, controlsAPI, lang }: PresetControlsProps) => {
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className={styles.presetControls}>
<LevaButton onClick={handleExport} title="Export current preset">
<FileDownloadOutlinedIcon style={{ fontSize: '14px', marginRight: '4px' }} />
{lang['editor.export'] || 'Export'}
</LevaButton>
<LevaButton onClick={handleImportClick} title="Import preset from file">
<FileUploadOutlinedIcon style={{ fontSize: '14px', marginRight: '4px' }} />
{lang['editor.import'] || 'Import'}
</LevaButton>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
);
};
================================================
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<ResizeWindowCtrlRefType>;
extendBound?: {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
resizableRef?: Ref<Resizable | null>;
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 (
<Resizable
className={clsx(styles.resizable, className, {
[styles.disableResize]: disableResize,
})}
style={{ ...style, ...extendBoundStyle }}
size={size}
handleClasses={{
top: clsx(styles.handle, styles.handleTop),
bottom: clsx(styles.handle, styles.handleBottom),
left: clsx(styles.handle, styles.handleLeft),
right: clsx(styles.handle, styles.handleRight),
topLeft: clsx(styles.handle, styles.handleCorner, styles.handleTopLeft),
topRight: clsx(styles.handle, styles.handleCorner, styles.handleTopRight),
bottomLeft: clsx(styles.handle, styles.handleCorner, styles.handleBottomLeft),
bottomRight: clsx(styles.handle, styles.handleCorner, styles.handleBottomRight),
}}
ref={(ref) => {
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 ? (
<div className={styles.resizeHandle}>
<UnfoldMoreIcon style={{ fontSize: 16 }}></UnfoldMoreIcon>
</div>
) : undefined,
topLeft:
!disableMove && showMoveHandle ? (
<div className={styles.moveHandle} data-move-handle>
<ZoomOutMapIcon style={{ fontSize: 16 }}></ZoomOutMapIcon>
</div>
) : undefined,
}}
>
{children}
</Resizable>
);
};
================================================
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(
<StrictMode>
<App />
</StrictMode>,
)
================================================
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<b.x)?h:1.0-h) );
// /*
// k *= 4.0;
// float h = max( k-abs(a.x-b.x), 0.0 )/k;
// float m = h*h*k*(1.0/4.0);
// float n = h*(1.0/2.0);
// return (a.x<b.x) ? vec3(a.x-m, mix(a.yz, b.yz, n) ):
// vec3(b.x-m, mix(a.yz, b.yz, 1.0-n) );
// */
// }
vec3 sdgMin(vec3 a, vec3 b) {
return a.x < b.x
? a
: b;
}
// quartic polynomial
// float smin( float a, float b, float k )
// {
// k *= 16.0/3.0;
// float x = (b-a)/k;
// float g = (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
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
SYMBOL INDEX (124 symbols across 15 files)
FILE: src/App.tsx
function App (line 64) | function App() {
FILE: src/components/LevaButton/LevaButton.tsx
type LevaButtonProps (line 6) | type LevaButtonProps = {
FILE: src/components/LevaCheckButtons/LevaCheckButtons.tsx
type CheckButtonsSettings (line 9) | type CheckButtonsSettings = {
type CheckButtonsValueType (line 15) | type CheckButtonsValueType = string[];
type CheckButtonsProps (line 16) | type CheckButtonsProps = CheckButtonsSettings & { selected: CheckButtons...
type CheckButtonsLevaProps (line 18) | type CheckButtonsLevaProps = LevaInputProps<CheckButtonsValueType, Check...
function LevaCheckButtonsComponent (line 20) | function LevaCheckButtonsComponent() {
FILE: src/components/LevaContainer/LevaContainer.tsx
type ContainerSettings (line 9) | type ContainerSettings = {
type ContainerValueType (line 21) | type ContainerValueType = { contentValue: any };
type ContainerProps (line 22) | type ContainerProps = ContainerValueType & ContainerSettings;
type ContainerInputProps (line 24) | type ContainerInputProps = LevaInputProps<ContainerValueType, ContainerS...
function ContainerComponent (line 26) | function ContainerComponent() {
FILE: src/components/LevaImageUpload/LevaImageUpload.tsx
type ImageUploadSettings (line 8) | type ImageUploadSettings = {
type ImageUploadValueType (line 17) | type ImageUploadValueType = { file?: string };
type ImageUploadProps (line 18) | type ImageUploadProps = ImageUploadValueType & ImageUploadSettings;
type ImageUploadLevaProps (line 20) | type ImageUploadLevaProps = LevaInputProps<ImageUploadValueType, ImageUp...
function GreenOrBlue (line 22) | function GreenOrBlue() {
FILE: src/components/LevaVectorNew/LevaVectorNew.tsx
type VectorNewSettings (line 8) | type VectorNewSettings = {
type VectorNewValueType (line 19) | type VectorNewValueType = { x: number; y: number };
type VectorNewProps (line 20) | type VectorNewProps = VectorNewValueType & VectorNewSettings;
type VectorNewLevaProps (line 22) | type VectorNewLevaProps = LevaInputProps<VectorNewValueType, VectorNewSe...
type JoyStickProps (line 24) | type JoyStickProps = {
function Joystick (line 31) | function Joystick({
function LevaVectorNewComponent (line 238) | function LevaVectorNewComponent() {
FILE: src/components/PresetControls/PresetControls.tsx
type PresetControlsProps (line 9) | interface PresetControlsProps {
FILE: src/components/ResizableWindow/ResizableWindow.tsx
type ResizeWindowCtrlRefType (line 9) | type ResizeWindowCtrlRefType = {
type Props (line 14) | type Props = {
method preventDefault (line 210) | preventDefault() {
FILE: src/utils/GLUtils.ts
type GL (line 3) | type GL = WebGL2RenderingContext;
type ShaderSource (line 5) | interface ShaderSource {
type AttributeInfo (line 10) | interface AttributeInfo {
type UniformInfo (line 16) | interface UniformInfo {
type RenderPassConfig (line 25) | interface RenderPassConfig {
class ShaderProgram (line 33) | class ShaderProgram {
method constructor (line 39) | constructor(gl: GL, source: ShaderSource) {
method createShader (line 46) | private createShader(type: number, source: string): WebGLShader {
method createProgram (line 63) | private createProgram(source: ShaderSource): WebGLProgram {
method detectAttributes (line 90) | private detectAttributes(): void {
method detectUniforms (line 112) | private detectUniforms(): void {
method use (line 150) | public use(): void {
method setUniform (line 154) | public setUniform(name: string, value: any): void {
method getAttributeLocation (line 208) | public getAttributeLocation(name: string): number {
method dispose (line 213) | public dispose(): void {
class FrameBuffer (line 239) | class FrameBuffer {
method constructor (line 247) | constructor(gl: GL, width: number, height: number) {
method createFramebuffer (line 259) | private createFramebuffer() {
method bind (line 332) | public bind(): void {
method unbind (line 336) | public unbind(): void {
method getTexture (line 340) | public getTexture(): WebGLTexture {
method getDepthTexture (line 344) | public getDepthTexture(): WebGLTexture {
method resize (line 348) | public resize(width: number, height: number): void {
method dispose (line 382) | public dispose(): void {
class RenderPass (line 391) | class RenderPass {
method constructor (line 398) | constructor(
method createVAO (line 412) | private createVAO(): WebGLVertexArrayObject {
method setConfig (line 441) | public setConfig(config: RenderPassConfig) {
method render (line 445) | public render(uniforms?: Record<string, any>): void {
method getOutputTexture (line 484) | public getOutputTexture(): WebGLTexture | null {
method resize (line 488) | public resize(width: number, height: number): void {
method dispose (line 494) | public dispose(): void {
class MultiPassRenderer (line 512) | class MultiPassRenderer {
method constructor (line 518) | constructor(canvas: HTMLCanvasElement, configs: RenderPassConfig[]) {
method resize (line 538) | public resize(width: number, height: number): void {
method setUniform (line 549) | public setUniform(name: string, value: any): void {
method setUniforms (line 557) | public setUniforms(uniforms: Record<string, any>): void {
method clearUniform (line 565) | public clearUniform(name: string): void {
method clearAllUniforms (line 572) | public clearAllUniforms(): void {
method render (line 576) | public render(passUniforms?: Record<string, any>[] | Record<string, Re...
method dispose (line 612) | public dispose(): void {
function loadTextureFromURL (line 629) | function loadTextureFromURL(gl: WebGL2RenderingContext, url: string): Pr...
function createEmptyTexture (line 659) | function createEmptyTexture(gl: WebGL2RenderingContext): WebGLTexture {
function updateVideoTexture (line 679) | function updateVideoTexture(
FILE: src/utils/GPUUtils.ts
class GPUFrameBuffer (line 6) | class GPUFrameBuffer {
method constructor (line 15) | constructor(device: GPUDevice, width: number, height: number) {
method createTextures (line 26) | private createTextures() {
method colorTexture (line 40) | get colorTexture(): GPUTexture { return this._colorTexture; }
method colorView (line 41) | get colorView(): GPUTextureView { return this._colorView; }
method depthView (line 42) | get depthView(): GPUTextureView { return this._depthView; }
method resize (line 44) | resize(width: number, height: number): void {
method dispose (line 57) | dispose(): void {
type PassType (line 66) | type PassType = 'bg' | 'blur' | 'main';
function detectPassType (line 68) | function detectPassType(config: RenderPassConfig): PassType {
class GPURenderPassObj (line 76) | class GPURenderPassObj {
method constructor (line 87) | constructor(
method createBindGroupLayout (line 141) | private createBindGroupLayout(): GPUBindGroupLayout {
method render (line 172) | render(
method getOutputTexture (line 196) | getOutputTexture(): GPUTexture | null {
method getBindGroupLayout (line 200) | getBindGroupLayout(): GPUBindGroupLayout {
method getPassType (line 204) | getPassType(): PassType {
method resize (line 208) | resize(width: number, height: number): void {
method dispose (line 212) | dispose(): void {
class GPUMultiPassRenderer (line 220) | class GPUMultiPassRenderer implements IMultiPassRenderer {
method constructor (line 236) | constructor(
method resize (line 281) | resize(width: number, height: number): void {
method setUniform (line 287) | setUniform(name: string, value: any): void {
method setUniforms (line 291) | setUniforms(uniforms: Record<string, any>): void {
method clearUniform (line 295) | clearUniform(name: string): void {
method clearAllUniforms (line 299) | clearAllUniforms(): void {
method render (line 303) | render(passUniforms?: Record<string, any>[] | Record<string, Record<st...
method buildBindGroup (line 346) | private buildBindGroup(
method buildBgBindGroup (line 362) | private buildBgBindGroup(pass: GPURenderPassObj, uniforms: Record<stri...
method buildBlurBindGroup (line 376) | private buildBlurBindGroup(
method buildMainBindGroup (line 420) | private buildMainBindGroup(
method createMainUniformBuffer (line 480) | private createMainUniformBuffer(uniforms: Record<string, any>): GPUBuf...
method dispose (line 566) | dispose(): void {
function gpuLoadTextureFromURL (line 581) | async function gpuLoadTextureFromURL(
function gpuCreateEmptyTexture (line 616) | function gpuCreateEmptyTexture(device: GPUDevice, width = 1, height = 1)...
function gpuUpdateVideoTexture (line 624) | async function gpuUpdateVideoTexture(
FILE: src/utils/RendererInterface.ts
type RenderPassConfig (line 6) | interface RenderPassConfig {
type ITextureHandle (line 20) | type ITextureHandle = WebGLTexture | GPUTexture;
type IMultiPassRenderer (line 26) | interface IMultiPassRenderer {
type TextureLoadResult (line 39) | interface TextureLoadResult<T> {
FILE: src/utils/gpuDetect.ts
type WebGPUDetectResult (line 4) | interface WebGPUDetectResult {
function detectWebGPU (line 14) | async function detectWebGPU(): Promise<WebGPUDetectResult> {
function getWebGPUDetectResult (line 54) | function getWebGPUDetectResult(): WebGPUDetectResult | null {
FILE: src/utils/index.ts
function computeGaussianKernelByRadius (line 1) | function computeGaussianKernelByRadius(radius: number) {
function isChineseLanguage (line 13) | function isChineseLanguage() {
function isUzbekLanguage (line 17) | function isUzbekLanguage() {
function capitalize (line 21) | function capitalize(str: string) {
FILE: src/utils/presetUtils.ts
type PresetData (line 2) | interface PresetData {
function exportPreset (line 8) | function exportPreset(
function importPreset (line 31) | function importPreset(file: File): Promise<PresetData> {
FILE: src/utils/useResizeOberver.ts
type ObserveCb (line 1) | type ObserveCb = (rect: DOMRect, target: HTMLElement) => void;
function useResizeObserver (line 62) | function useResizeObserver() {
Condensed preview — 76 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (291K chars).
[
{
"path": ".editorconfig",
"chars": 194,
"preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ni"
},
{
"path": ".gitignore",
"chars": 297,
"preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
},
{
"path": ".prettierrc.js",
"chars": 97,
"preview": "export default {\n printWidth: 100,\n singleQuote: true,\n plugins: ['prettier-plugin-glsl'],\n};\n"
},
{
"path": "LICENSE",
"chars": 1068,
"preview": "MIT License\n\nCopyright (c) 2024 Charles Yin\n\nPermission is hereby granted, free of charge, to any person obtaining a cop"
},
{
"path": "README-uz.md",
"chars": 5836,
"preview": "# 🔮 Liquid Glass Studio\n\n\n\n[English](README.md) | [简体中文](README-zh.md)\n\nWebGL2 "
},
{
"path": "README-zh.md",
"chars": 2044,
"preview": "# 🔮 Liquid Glass Studio\n\n\n\n[English](README.md) | [简体中文](README-zh.md)\n\nApple L"
},
{
"path": "README.md",
"chars": 3200,
"preview": "# 🔮 Liquid Glass Studio\n\n\n\n[English](README.md) | [简体中文](README-zh.md) | [O‘zbe"
},
{
"path": "eslint.config.js",
"chars": 734,
"preview": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reac"
},
{
"path": "index.html",
"chars": 368,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <link rel=\"icon\" type=\"image/svg+xml\" href=\"/"
},
{
"path": "openspec/changes/add-webgpu-backend/design.md",
"chars": 7166,
"preview": "## Context\nThe current rendering pipeline is tightly coupled to WebGL2 via `GLUtils.ts` (ShaderProgram, FrameBuffer, Ren"
},
{
"path": "openspec/changes/add-webgpu-backend/proposal.md",
"chars": 1222,
"preview": "# Change: Add WebGPU rendering backend with runtime toggle\n\n## Why\nWebGPU offers better performance, modern shader capab"
},
{
"path": "openspec/changes/add-webgpu-backend/specs/glass-rendering/spec.md",
"chars": 6698,
"preview": "## MODIFIED Requirements\n\n### Requirement: Multi-Pass Rendering Pipeline\nThe system SHALL render the glass effect using "
},
{
"path": "openspec/changes/add-webgpu-backend/specs/parameter-controls/spec.md",
"chars": 1179,
"preview": "## ADDED Requirements\n\n### Requirement: Renderer Backend Toggle\nThe system SHALL display a custom Leva toggle component "
},
{
"path": "openspec/changes/add-webgpu-backend/tasks.md",
"chars": 4103,
"preview": "## 1. Renderer Abstraction Interface\n- [x] 1.1 Define `IMultiPassRenderer` interface in `src/utils/RendererInterface.ts`"
},
{
"path": "openspec/changes/archive/2026-03-23-add-initial-specs/proposal.md",
"chars": 786,
"preview": "# Change: Add initial specifications for existing capabilities\n\n## Why\nThe project has no specifications documenting its"
},
{
"path": "openspec/changes/archive/2026-03-23-add-initial-specs/specs/background-system/spec.md",
"chars": 2123,
"preview": "## ADDED Requirements\n\n### Requirement: Procedural Backgrounds\nThe system SHALL provide built-in procedural background p"
},
{
"path": "openspec/changes/archive/2026-03-23-add-initial-specs/specs/glass-rendering/spec.md",
"chars": 4407,
"preview": "## ADDED Requirements\n\n### Requirement: Multi-Pass Rendering Pipeline\nThe system SHALL render the glass effect using a f"
},
{
"path": "openspec/changes/archive/2026-03-23-add-initial-specs/specs/parameter-controls/spec.md",
"chars": 3026,
"preview": "## ADDED Requirements\n\n### Requirement: Leva Parameter Panel\nThe system SHALL provide a real-time parameter editor using"
},
{
"path": "openspec/changes/archive/2026-03-23-add-initial-specs/specs/preset-management/spec.md",
"chars": 980,
"preview": "## ADDED Requirements\n\n### Requirement: Preset Export\nThe system SHALL allow users to export all current control paramet"
},
{
"path": "openspec/changes/archive/2026-03-23-add-initial-specs/specs/shape-system/spec.md",
"chars": 2647,
"preview": "## ADDED Requirements\n\n### Requirement: Superellipse Shape\nThe system SHALL render the primary glass shape as a rounded "
},
{
"path": "openspec/changes/archive/2026-03-23-add-initial-specs/tasks.md",
"chars": 614,
"preview": "## 1. Documentation\n- [x] 1.1 Update `openspec/project.md` with project context, tech stack, conventions, and domain kno"
},
{
"path": "openspec/project.md",
"chars": 4850,
"preview": "# Project Context\n\n## Purpose\nLiquid Glass Studio is a web-based interactive tool that recreates Apple's Liquid Glass UI"
},
{
"path": "openspec/specs/background-system/spec.md",
"chars": 2246,
"preview": "# background-system Specification\n\n## Purpose\nTBD - created by archiving change add-initial-specs. Update Purpose after "
},
{
"path": "openspec/specs/glass-rendering/spec.md",
"chars": 4528,
"preview": "# glass-rendering Specification\n\n## Purpose\nTBD - created by archiving change add-initial-specs. Update Purpose after ar"
},
{
"path": "openspec/specs/parameter-controls/spec.md",
"chars": 3150,
"preview": "# parameter-controls Specification\n\n## Purpose\nTBD - created by archiving change add-initial-specs. Update Purpose after"
},
{
"path": "openspec/specs/preset-management/spec.md",
"chars": 1103,
"preview": "# preset-management Specification\n\n## Purpose\nTBD - created by archiving change add-initial-specs. Update Purpose after "
},
{
"path": "openspec/specs/shape-system/spec.md",
"chars": 2765,
"preview": "# shape-system Specification\n\n## Purpose\nTBD - created by archiving change add-initial-specs. Update Purpose after archi"
},
{
"path": "package.json",
"chars": 1091,
"preview": "{\n \"name\": \"liquid-glass\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite"
},
{
"path": "src/App.module.scss",
"chars": 3948,
"preview": ".header {\n position: fixed;\n left: 50%;\n top: 12px;\n padding: 8px 12px;\n gap: 8px;\n transform: translateX(-50%);\n "
},
{
"path": "src/App.tsx",
"chars": 28956,
"preview": "import {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n} from 'r"
},
{
"path": "src/Controls.tsx",
"chars": 8381,
"preview": "import { useControls, folder, Leva } from 'leva';\nimport { isChineseLanguage, isUzbekLanguage } from './utils';\nimport {"
},
{
"path": "src/components/LevaButton/LevaButton.scss",
"chars": 540,
"preview": ".leva-button-custom {\n padding: 0px 8px;\n border-radius: var(--leva-radii-sm);\n background: var(--leva-colors-elevati"
},
{
"path": "src/components/LevaButton/LevaButton.tsx",
"chars": 740,
"preview": "import clsx from 'clsx';\nimport React from 'react';\n\nimport './LevaButton.scss';\n\nexport type LevaButtonProps = {\n chil"
},
{
"path": "src/components/LevaButton/index.ts",
"chars": 43,
"preview": "export { LevaButton } from './LevaButton';\n"
},
{
"path": "src/components/LevaCheckButtons/LevaCheckButtons.scss",
"chars": 748,
"preview": ".leva-check-buttons {\n &__button-group {\n display: flex;\n align-items: center;\n\n .leva-button-custom {\n p"
},
{
"path": "src/components/LevaCheckButtons/LevaCheckButtons.tsx",
"chars": 3285,
"preview": "/* eslint-disable @typescript-eslint/no-unused-vars */\n// import { CSSProperties, useEffect, useLayoutEffect, useMemo, u"
},
{
"path": "src/components/LevaCheckButtons/index.ts",
"chars": 55,
"preview": "export { LevaCheckButtons } from './LevaCheckButtons';\n"
},
{
"path": "src/components/LevaContainer/LevaContainer.scss",
"chars": 310,
"preview": ".leva-container {\n $s: &;\n\n &.leva-container--with-label {\n grid-template-columns: var(--label-width) var(--content"
},
{
"path": "src/components/LevaContainer/LevaContainer.tsx",
"chars": 2875,
"preview": "/* eslint-disable @typescript-eslint/no-unused-vars */\nimport { createPlugin, useInputContext, type LevaInputProps, Comp"
},
{
"path": "src/components/LevaContainer/index.ts",
"chars": 0,
"preview": ""
},
{
"path": "src/components/LevaImageUpload/LevaImageUpload.scss",
"chars": 2062,
"preview": ".leva-image-upload {\n $s: &;\n\n div:first-child {\n padding-top: 4px;\n align-items: flex-start;\n }\n\n &--disabled"
},
{
"path": "src/components/LevaImageUpload/LevaImageUpload.tsx",
"chars": 4403,
"preview": "import { type CSSProperties, useEffect, useMemo, useRef } from 'react';\nimport './LevaImageUpload.scss';\nimport { create"
},
{
"path": "src/components/LevaVectorNew/LevaVectorNew.scss",
"chars": 1697,
"preview": ".leva-vector-new {\n &__input-wrapper {\n display: flex;\n gap: var(--leva-space-colGap);\n\n div:nth-child(2),\n "
},
{
"path": "src/components/LevaVectorNew/LevaVectorNew.tsx",
"chars": 10685,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { type CSSProperties, useLayoutEffect, useRef, useState }"
},
{
"path": "src/components/PresetControls/PresetControls.module.scss",
"chars": 90,
"preview": ".presetControls {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n position: absolute;\n}\n"
},
{
"path": "src/components/PresetControls/PresetControls.tsx",
"chars": 2560,
"preview": "import { useRef } from 'react';\nimport { LevaButton } from '../LevaButton/LevaButton';\nimport { exportPreset, importPres"
},
{
"path": "src/components/ResizableWindow/ResizableWindow.module.scss",
"chars": 2626,
"preview": ".resizable {\n outline: 1px solid rgba(black, 0.1);\n box-shadow: 0 2px 7px rgba(0, 0, 0, 0.5);\n // margin-left: 20px;\n"
},
{
"path": "src/components/ResizableWindow/ResizableWindow.tsx",
"chars": 7326,
"preview": "import clsx from 'clsx';\nimport { Resizable } from 're-resizable';\nimport UnfoldMoreIcon from '@mui/icons-material/Unfol"
},
{
"path": "src/components/ResizableWindow/index.tsx",
"chars": 53,
"preview": "export { ResizableWindow } from './ResizableWindow';\n"
},
{
"path": "src/index.scss",
"chars": 752,
"preview": "@import '../node_modules/modern-normalize/modern-normalize.css';\n\nbody {\n --bg-color: #2a2a33;\n --bg-color-fore: #4747"
},
{
"path": "src/main.tsx",
"chars": 231,
"preview": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.scss'\nimport App from '"
},
{
"path": "src/shaders/fragment-bg-hblur.glsl",
"chars": 665,
"preview": "#version 300 es\n\nprecision highp float;\n\n#define MAX_BLUR_RADIUS (200)\n\nin vec2 v_uv;\n\nuniform sampler2D u_prevPassTextu"
},
{
"path": "src/shaders/fragment-bg-vblur.glsl",
"chars": 665,
"preview": "#version 300 es\n\nprecision highp float;\n\n#define MAX_BLUR_RADIUS (200)\n\nin vec2 v_uv;\n\nuniform sampler2D u_prevPassTextu"
},
{
"path": "src/shaders/fragment-bg.glsl",
"chars": 4714,
"preview": "#version 300 es\n\nprecision highp float;\n\nin vec2 v_uv;\nout vec4 fragColor;\n\nuniform vec2 u_resolution;\nuniform float u_d"
},
{
"path": "src/shaders/fragment-main-1.glsl",
"chars": 9425,
"preview": "#version 300 es\n\nprecision highp float;\n\nin vec4 v_color;\n\nout vec4 fragColor;\n\nuniform vec2 u_resolution;\nuniform vec2 "
},
{
"path": "src/shaders/fragment-main.glsl",
"chars": 22292,
"preview": "#version 300 es\n\nprecision highp float;\n\n#define PI (3.14159265359)\n\nconst float N_R = 1.0 - 0.02;\nconst float N_G = 1.0"
},
{
"path": "src/shaders/test.glsl",
"chars": 12021,
"preview": "#version 300 es\nprecision mediump float;\nin vec3 vVertexPosition;\nin vec2 vTextureCoord;\nuniform sampler2D uTexture;\nuni"
},
{
"path": "src/shaders/vertex.glsl",
"chars": 201,
"preview": "#version 300 es\n\nin vec4 a_position;\nout vec2 v_uv;\n// in vec4 a_color;\n\n// out vec4 v_color;\n\nvoid main() {\n v_uv = (a"
},
{
"path": "src/shaders-wgsl/fragment-bg-hblur.wgsl",
"chars": 1048,
"preview": "// Horizontal Gaussian blur pass for WebGPU\n// Ported from fragment-bg-hblur.glsl (blurs along X axis)\n\nconst MAX_BLUR_R"
},
{
"path": "src/shaders-wgsl/fragment-bg-vblur.wgsl",
"chars": 1046,
"preview": "// Vertical Gaussian blur pass for WebGPU\n// Ported from fragment-bg-vblur.glsl (blurs along Y axis)\n\nconst MAX_BLUR_RAD"
},
{
"path": "src/shaders-wgsl/fragment-bg.wgsl",
"chars": 5159,
"preview": "// Background pass fragment shader for WebGPU\n// Ported from fragment-bg.glsl\n\nstruct Uniforms {\n u_resolution: vec2f,\n"
},
{
"path": "src/shaders-wgsl/fragment-main.wgsl",
"chars": 11893,
"preview": "// Main glass effect composition shader for WebGPU\n// Ported from fragment-main.glsl (STEP==9 branch only)\n\nconst PI: f3"
},
{
"path": "src/shaders-wgsl/vertex.wgsl",
"chars": 629,
"preview": "// Fullscreen quad vertex shader for WebGPU\n// Similar to vertex.glsl but with v_uv.y flipped so that v_uv=(0,0) is top-"
},
{
"path": "src/utils/GLUtils.ts",
"chars": 17894,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n// 基础类型定义\ntype GL = WebGL2RenderingContext;\n\ninterface ShaderSou"
},
{
"path": "src/utils/GPUUtils.ts",
"chars": 21855,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { IMultiPassRenderer, RenderPassConfig } from './Ren"
},
{
"path": "src/utils/RendererInterface.ts",
"chars": 1163,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\n/**\n * Common render pass configuration shared by both WebGL2 a"
},
{
"path": "src/utils/gpuDetect.ts",
"chars": 1525,
"preview": "/**\n * Detect WebGPU support in the current browser environment.\n */\nexport interface WebGPUDetectResult {\n supported: "
},
{
"path": "src/utils/index.ts",
"chars": 621,
"preview": "export function computeGaussianKernelByRadius(radius: number) {\n const sigma = radius / 3.0;\n const kernel = [];\n let"
},
{
"path": "src/utils/languages.ts",
"chars": 5485,
"preview": "export default {\n ['zh-CN']: {\n '_settings': {\n rootWidth: '350px',\n numberInputMinWidth: '42px',\n co"
},
{
"path": "src/utils/presetUtils.ts",
"chars": 1530,
"preview": "import { type useLevaControls } from '../Controls';\nexport interface PresetData {\n version: string;\n timestamp: string"
},
{
"path": "src/utils/useResizeOberver.ts",
"chars": 1367,
"preview": "type ObserveCb = (rect: DOMRect, target: HTMLElement) => void;\n\n// Global observer\nlet resizeObserver: ResizeObserver | "
},
{
"path": "src/vite-env.d.ts",
"chars": 38,
"preview": "/// <reference types=\"vite/client\" />\n"
},
{
"path": "tsconfig.app.json",
"chars": 827,
"preview": "{\n \"compilerOptions\": {\n \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n \"target\": \"ES2020\",\n"
},
{
"path": "tsconfig.json",
"chars": 119,
"preview": "{\n \"files\": [],\n \"references\": [\n { \"path\": \"./tsconfig.app.json\" },\n { \"path\": \"./tsconfig.node.json\" }\n ]\n}\n"
},
{
"path": "tsconfig.node.json",
"chars": 630,
"preview": "{\n \"compilerOptions\": {\n \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n \"target\": \"ES2022\","
},
{
"path": "vite.config.ts",
"chars": 294,
"preview": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react-swc'\nimport tsconfigPaths from 'vite-tsconfi"
}
]
About this extraction
This page contains the full source code of the iyinchao/liquid-glass-studio GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 76 files (267.6 KB), approximately 80.8k tokens, and a symbol index with 124 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.