Compare commits
20 Commits
64bea69036
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9db1479cc0 | |||
| 2cee4084c0 | |||
| 3eeafc1277 | |||
| 431966d498 | |||
| 2cf306ee8c | |||
| d64b3839e8 | |||
| 80537a4a30 | |||
| 9bf5d40171 | |||
| 3dcbbfd9c2 | |||
| e0e860f9c9 | |||
| fb2d5c1b4c | |||
| 6d5aa9f0f5 | |||
| 10b70ffc54 | |||
| 8aad6554ed | |||
| bf5085431a | |||
| 3a5b38bd70 | |||
| ec8786ab9b | |||
| f84b515523 | |||
| 96af50ee6b | |||
| afd0fd84f6 |
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.js
|
||||
public
|
||||
38
.eslintrc.json
Normal file
38
.eslintrc.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
@ -25,6 +25,7 @@ npm run dev
|
||||
- **H** - Toggle minimal UI mode
|
||||
- **F11** - Fullscreen
|
||||
- **R** - Random shader
|
||||
- **M** - Cycle value modes
|
||||
- **S** - Share URL
|
||||
|
||||
### Shader Variables
|
||||
|
||||
@ -2,3 +2,6 @@ x<<127*y*t
|
||||
x<<20*t*80*y^8
|
||||
x**10*y^200*t+20
|
||||
x**10*y^200*t+20
|
||||
x ^ Math.sin(y ^ x) * Math.sin(t) * Math.PI * 200
|
||||
x ^ Math.sin(y ^ x) * Math.sin(t) * Math.PI ** 4 | x % 40
|
||||
(x | y + 20 + y*8 * bassLevel * t * audioLevel) + ([x>y, x<y, x&y, x|y][floor(t) * 2 % 3] ? max(y *t, sin(x * t)) : max(x ** y, cos(y * t)))
|
||||
|
||||
960
index.html
960
index.html
@ -27,966 +27,12 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--ui-opacity: 0.3;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation; /* Allow pan and zoom but disable double-tap zoom */
|
||||
}
|
||||
|
||||
#canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
touch-action: none; /* Disable all touch gestures on canvas for shader interaction */
|
||||
pointer-events: auto; /* Allow canvas interactions */
|
||||
}
|
||||
|
||||
#topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
pointer-events: auto; /* Ensure topbar can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#topbar .title {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
#topbar .controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#topbar .controls-desktop {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#topbar .controls-mobile {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#hamburger-menu {
|
||||
display: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#hamburger-menu:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
#hamburger-menu svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
#mobile-menu {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
right: -320px;
|
||||
width: 320px;
|
||||
max-width: 80vw;
|
||||
height: calc(100vh - 40px);
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
backdrop-filter: blur(3px);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 150;
|
||||
transition: right 0.3s ease;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
pointer-events: auto; /* Ensure mobile menu can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#mobile-menu.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#mobile-menu h3 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mobile-menu-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mobile-menu-item {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.mobile-menu-item label {
|
||||
display: block;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.mobile-menu-item select,
|
||||
.mobile-menu-item input[type="range"] {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mobile-menu-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-menu-buttons button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-menu-buttons button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
#mobile-menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 149;
|
||||
pointer-events: none; /* Don't block clicks when hidden */
|
||||
}
|
||||
|
||||
#mobile-menu-overlay.open {
|
||||
display: block;
|
||||
pointer-events: auto; /* Only block clicks when visible */
|
||||
}
|
||||
|
||||
#topbar button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#topbar button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Lucide icon styles */
|
||||
[data-lucide] {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
button svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Ensure all button contents don't intercept clicks */
|
||||
button *, button svg, button [data-lucide] {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#editor-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: auto; /* Ensure editor panel can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#editor-panel.minimal {
|
||||
height: 50px;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
backdrop-filter: blur(2px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
touch-action: manipulation; /* Allow normal touch interactions for text editing */
|
||||
}
|
||||
|
||||
#eval-btn {
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
backdrop-filter: blur(2px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding: 20px 30px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
#eval-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
#eval-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
#editor.minimal {
|
||||
padding: 12px 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#eval-btn.minimal {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#help-popup {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
z-index: 1000;
|
||||
max-width: 90vw;
|
||||
width: 800px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
pointer-events: auto; /* Ensure help popup can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#help-popup h3 {
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#help-popup .help-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#help-popup .help-section h4 {
|
||||
color: #ccc;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
#help-popup .help-section p {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#help-popup .close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 15px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#show-ui-btn {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#show-ui-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
#shader-library {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: -300px;
|
||||
width: 300px;
|
||||
height: calc(100vh - 40px);
|
||||
background: rgba(0, 0, 0, calc(var(--ui-opacity) + 0.1));
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 90;
|
||||
transition: left 0.3s ease;
|
||||
backdrop-filter: blur(3px);
|
||||
overflow-y: auto;
|
||||
pointer-events: auto; /* Ensure shader library can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
|
||||
#shader-library-trigger {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: 0;
|
||||
width: 20px;
|
||||
height: calc(100vh - 40px);
|
||||
z-index: 91;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#shader-library-trigger:hover + #shader-library,
|
||||
#shader-library:hover {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#shader-library.open {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.library-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.library-header h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.save-shader {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-shader {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search-shader input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-shader input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.save-shader input {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-shader button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-shader button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.shader-list {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
|
||||
.shader-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shader-item-header {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shader-item-header:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.shader-name {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.shader-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shader-action {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.shader-action:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.shader-action.rename {
|
||||
background: rgba(52, 152, 219, 0.3);
|
||||
border-color: rgba(52, 152, 219, 0.5);
|
||||
}
|
||||
|
||||
.shader-action.rename:hover {
|
||||
background: rgba(52, 152, 219, 0.5);
|
||||
}
|
||||
|
||||
.shader-action.delete {
|
||||
background: rgba(231, 76, 60, 0.3);
|
||||
border-color: rgba(231, 76, 60, 0.5);
|
||||
}
|
||||
|
||||
.shader-action.delete:hover {
|
||||
background: rgba(231, 76, 60, 0.5);
|
||||
}
|
||||
|
||||
.shader-code {
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
color: #ccc;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#performance-warning {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 20px;
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
#topbar .controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#topbar .controls-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#topbar .controls-mobile {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#hamburger-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#topbar .title {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#topbar .controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
#topbar button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#topbar label {
|
||||
font-size: 11px !important;
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
#topbar select {
|
||||
padding: 2px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
#help-popup {
|
||||
width: 95vw;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#editor-panel {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#shader-library {
|
||||
width: 100%;
|
||||
left: -100%;
|
||||
top: 40px;
|
||||
height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
#shader-library-trigger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#topbar {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#topbar .title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#topbar button {
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#topbar label {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
#topbar select {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
#help-popup {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
#help-popup h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#help-popup .help-section h4 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#help-popup .help-section p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#editor-panel {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.help-content {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/src/styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
|
||||
<button id="show-ui-btn">Show UI</button>
|
||||
|
||||
<div id="topbar">
|
||||
<div class="title">Bitfielder</div>
|
||||
<div class="controls">
|
||||
<div class="controls-desktop">
|
||||
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
|
||||
Resolution:
|
||||
<select id="resolution-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
|
||||
<option value="1">Full (1x)</option>
|
||||
<option value="2">Half (2x)</option>
|
||||
<option value="4">Quarter (4x)</option>
|
||||
<option value="8">Eighth (8x)</option>
|
||||
<option value="16">Sixteenth (16x)</option>
|
||||
<option value="32">Thirty-second (32x)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
|
||||
FPS:
|
||||
<select id="fps-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
|
||||
<option value="15">15 FPS</option>
|
||||
<option value="30" selected>30 FPS</option>
|
||||
<option value="60">60 FPS</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
|
||||
Render Mode:
|
||||
<select id="render-mode-select" style="background: rgba(255,255,255,0.1); border: 1px solid #555; color: #fff; padding: 4px; border-radius: 4px;">
|
||||
<option value="classic" selected>Classic</option>
|
||||
<option value="grayscale">Grayscale</option>
|
||||
<option value="red">Red Channel</option>
|
||||
<option value="green">Green Channel</option>
|
||||
<option value="blue">Blue Channel</option>
|
||||
<option value="rgb">RGB Split</option>
|
||||
<option value="hsv">HSV</option>
|
||||
<option value="rainbow">Rainbow</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="color: #ccc; font-size: 12px; margin-right: 10px;">
|
||||
UI Opacity:
|
||||
<input type="range" id="opacity-slider" min="10" max="100" value="30" style="width: 80px; vertical-align: middle;">
|
||||
<span id="opacity-value" style="font-size: 11px;">30%</span>
|
||||
</label>
|
||||
<button id="help-btn">?</button>
|
||||
<button id="fullscreen-btn">Fullscreen</button>
|
||||
<button id="hide-ui-btn">Hide UI</button>
|
||||
<button id="random-btn">Random</button>
|
||||
<button id="audio-btn">Enable Audio</button>
|
||||
<button id="share-btn">Share</button>
|
||||
<button id="export-png-btn">Export PNG</button>
|
||||
</div>
|
||||
<div class="controls-mobile">
|
||||
<button id="library-btn-mobile" class="icon-button" aria-label="Shader Library"></button>
|
||||
<button id="random-btn-mobile" class="icon-button" aria-label="Random"></button>
|
||||
<button id="hide-ui-btn-mobile" class="icon-button" aria-label="Hide UI"></button>
|
||||
<button id="hamburger-menu" class="icon-button" aria-label="Menu"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mobile-menu-overlay"></div>
|
||||
<div id="mobile-menu">
|
||||
<h3>Settings</h3>
|
||||
<div class="mobile-menu-section">
|
||||
<div class="mobile-menu-item">
|
||||
<label>Resolution</label>
|
||||
<select id="resolution-select-mobile">
|
||||
<option value="1">Full (1x)</option>
|
||||
<option value="2">Half (2x)</option>
|
||||
<option value="4">Quarter (4x)</option>
|
||||
<option value="8">Eighth (8x)</option>
|
||||
<option value="16">Sixteenth (16x)</option>
|
||||
<option value="32">Thirty-second (32x)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mobile-menu-item">
|
||||
<label>FPS</label>
|
||||
<select id="fps-select-mobile">
|
||||
<option value="15">15 FPS</option>
|
||||
<option value="30" selected>30 FPS</option>
|
||||
<option value="60">60 FPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mobile-menu-item">
|
||||
<label>Render Mode</label>
|
||||
<select id="render-mode-select-mobile">
|
||||
<option value="classic" selected>Classic</option>
|
||||
<option value="grayscale">Grayscale</option>
|
||||
<option value="red">Red Channel</option>
|
||||
<option value="green">Green Channel</option>
|
||||
<option value="blue">Blue Channel</option>
|
||||
<option value="rgb">RGB Split</option>
|
||||
<option value="hsv">HSV</option>
|
||||
<option value="rainbow">Rainbow</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mobile-menu-item">
|
||||
<label>UI Opacity: <span id="opacity-value-mobile">30%</span></label>
|
||||
<input type="range" id="opacity-slider-mobile" min="10" max="100" value="30">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-menu-section">
|
||||
<div class="mobile-menu-buttons">
|
||||
<button id="help-btn-mobile"><span class="icon"></span> Help</button>
|
||||
<button id="fullscreen-btn-mobile"><span class="icon"></span> Fullscreen</button>
|
||||
<button id="audio-btn-mobile"><span class="icon"></span> Enable Audio</button>
|
||||
<button id="share-btn-mobile"><span class="icon"></span> Share</button>
|
||||
<button id="export-png-btn-mobile"><span class="icon"></span> Export PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editor-panel">
|
||||
<textarea id="editor" placeholder="Enter shader code... (x, y, t, i, mouseX, mouseY, mousePressed, touchCount, accelX, audioLevel, bassLevel...)" spellcheck="false">x^y</textarea>
|
||||
<button id="eval-btn">Eval</button>
|
||||
</div>
|
||||
|
||||
<div id="shader-library-trigger"></div>
|
||||
<div id="shader-library">
|
||||
<div class="library-header">
|
||||
<h3>Shader Library</h3>
|
||||
<div class="save-shader">
|
||||
<input type="text" id="shader-name-input" placeholder="Shader name..." maxlength="30">
|
||||
<button id="save-shader-btn">Save</button>
|
||||
</div>
|
||||
<div class="search-shader">
|
||||
<input type="text" id="shader-search-input" placeholder="Search shaders...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="shader-list" id="shader-list">
|
||||
<!-- Saved shaders will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="help-popup">
|
||||
<button class="close-btn">×</button>
|
||||
<h3>Bitfielder Help</h3>
|
||||
|
||||
<div class="help-content">
|
||||
<div class="help-section">
|
||||
<h4>Keyboard Shortcuts</h4>
|
||||
<p><strong>Ctrl+Enter</strong> - Execute shader code</p>
|
||||
<p><strong>F11</strong> - Toggle fullscreen</p>
|
||||
<p><strong>H</strong> - Hide/show UI</p>
|
||||
<p><strong>R</strong> - Generate random shader</p>
|
||||
<p><strong>S</strong> - Share current shader (copy URL)</p>
|
||||
<p><strong>?</strong> - Show this help</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Variables</h4>
|
||||
<p><strong>x, y</strong> - Pixel coordinates</p>
|
||||
<p><strong>t</strong> - Time (enables animation)</p>
|
||||
<p><strong>i</strong> - Pixel index</p>
|
||||
<p><strong>mouseX, mouseY</strong> - Mouse position (0.0 to 1.0)</p>
|
||||
<p><strong>mousePressed</strong> - Mouse button down (true/false)</p>
|
||||
<p><strong>mouseVX, mouseVY</strong> - Mouse velocity</p>
|
||||
<p><strong>mouseClickTime</strong> - Time since last click (ms)</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Touch & Gestures</h4>
|
||||
<p><strong>touchCount</strong> - Number of active touches</p>
|
||||
<p><strong>touch0X, touch0Y</strong> - Primary touch position</p>
|
||||
<p><strong>touch1X, touch1Y</strong> - Secondary touch position</p>
|
||||
<p><strong>pinchScale</strong> - Pinch zoom scale factor</p>
|
||||
<p><strong>pinchRotation</strong> - Pinch rotation angle</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Device Motion</h4>
|
||||
<p><strong>accelX, accelY, accelZ</strong> - Accelerometer data</p>
|
||||
<p><strong>gyroX, gyroY, gyroZ</strong> - Gyroscope rotation rates</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Audio Reactive</h4>
|
||||
<p><strong>audioLevel</strong> - Overall audio volume (0.0-1.0)</p>
|
||||
<p><strong>bassLevel</strong> - Low frequencies (0.0-1.0)</p>
|
||||
<p><strong>midLevel</strong> - Mid frequencies (0.0-1.0)</p>
|
||||
<p><strong>trebleLevel</strong> - High frequencies (0.0-1.0)</p>
|
||||
<p>Click "Enable Audio" to activate microphone</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Operators</h4>
|
||||
<p><strong>^ & |</strong> - XOR, AND, OR</p>
|
||||
<p><strong><< >></strong> - Bit shift left/right</p>
|
||||
<p><strong>+ - * / %</strong> - Math operations</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Shader Library</h4>
|
||||
<p>Hover over the <strong>right edge</strong> of the screen to access the shader library</p>
|
||||
<p>Save shaders with custom names and search through them</p>
|
||||
<p>Use <strong>edit</strong> to rename, <strong>del</strong> to delete</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Render Modes</h4>
|
||||
<p><strong>Classic</strong> - Original colorful mode</p>
|
||||
<p><strong>Grayscale</strong> - Black and white</p>
|
||||
<p><strong>Red/Green/Blue</strong> - Single color channels</p>
|
||||
<p><strong>HSV</strong> - Hue-based coloring</p>
|
||||
<p><strong>Rainbow</strong> - Spectrum coloring</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4>Export</h4>
|
||||
<p><strong>Export PNG</strong> - Save current frame as image</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section" style="grid-column: 1 / -1; margin-top: 20px; text-align: center; padding-top: 20px; border-bottom: none;">
|
||||
<h4>About</h4>
|
||||
<p><strong>Bitfielder</strong> - Interactive bitfield shader editor</p>
|
||||
<p>Created by <strong>BuboBubo</strong> (Raphaël Forment)</p>
|
||||
<p>Website: <a href="https://raphaelforment.fr" target="_blank" style="color: #4A9EFF;">raphaelforment.fr</a></p>
|
||||
<p>Source: <a href="https://git.raphaelforment.fr" target="_blank" style="color: #4A9EFF;">git.raphaelforment.fr</a></p>
|
||||
<p>License: <strong>AGPL 3.0</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="performance-warning">
|
||||
Performance warning: Shader taking too long to render!
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
<!-- PWA Service Worker Registration -->
|
||||
<script>
|
||||
|
||||
4769
package-lock.json
generated
4769
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@ -9,7 +9,8 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "echo 'No linter configured'"
|
||||
"lint": "eslint src --ext .ts,.tsx --fix && prettier --write src",
|
||||
"lint:check": "eslint src --ext .ts,.tsx && prettier --check src"
|
||||
},
|
||||
"keywords": [
|
||||
"shader",
|
||||
@ -35,7 +36,15 @@
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^4.0.0"
|
||||
},
|
||||
"files": [
|
||||
@ -44,6 +53,13 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"dependencies": {
|
||||
"lucide": "^0.525.0"
|
||||
"@nanostores/react": "^1.0.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"lucide": "^0.525.0",
|
||||
"nanostores": "^1.0.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
213
public/sw.js
213
public/sw.js
@ -1,50 +1,201 @@
|
||||
const CACHE_NAME = 'bitfielder-v1';
|
||||
const urlsToCache = [
|
||||
const CACHE_NAME = 'bitfielder-v2';
|
||||
const STATIC_CACHE = 'bitfielder-static-v2';
|
||||
const DYNAMIC_CACHE = 'bitfielder-dynamic-v2';
|
||||
|
||||
// Core app files that should always be cached
|
||||
const CORE_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/src/main.ts',
|
||||
'/src/FakeShader.ts',
|
||||
'/src/ShaderWorker.ts',
|
||||
'/src/Storage.ts',
|
||||
'/src/icons.ts',
|
||||
'/manifest.json'
|
||||
'/manifest.json',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png'
|
||||
];
|
||||
|
||||
// Install event - cache resources
|
||||
// Assets that can be cached dynamically
|
||||
const DYNAMIC_ASSETS_PATTERNS = [
|
||||
/\/src\/.+\.(ts|tsx|js|jsx)$/,
|
||||
/\/src\/.+\.css$/,
|
||||
/\/assets\/.+\.(js|css)$/,
|
||||
/fonts\.googleapis\.com/,
|
||||
/fonts\.gstatic\.com/
|
||||
];
|
||||
|
||||
// Install event - cache core resources
|
||||
self.addEventListener('install', event => {
|
||||
console.log('Service Worker installing...');
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Opened cache');
|
||||
return cache.addAll(urlsToCache);
|
||||
Promise.all([
|
||||
caches.open(STATIC_CACHE).then(cache => {
|
||||
console.log('Caching core assets');
|
||||
return cache.addAll(CORE_ASSETS);
|
||||
}),
|
||||
caches.open(DYNAMIC_CACHE).then(cache => {
|
||||
console.log('Dynamic cache initialized');
|
||||
// Pre-cache critical assets if they exist
|
||||
return cache.addAll([]).catch(() => {
|
||||
console.log('No additional assets to pre-cache');
|
||||
});
|
||||
})
|
||||
]).then(() => {
|
||||
console.log('Service Worker installed successfully');
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache when offline
|
||||
self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return cached version or fetch from network
|
||||
return response || fetch(event.request);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
// Activate event - clean up old caches and take control
|
||||
self.addEventListener('activate', event => {
|
||||
console.log('Service Worker activating...');
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return Promise.all([
|
||||
...cacheNames.map(cacheName => {
|
||||
if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
|
||||
console.log('Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
}),
|
||||
self.clients.claim()
|
||||
]);
|
||||
}).then(() => {
|
||||
console.log('Service Worker activated successfully');
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - sophisticated caching strategy
|
||||
self.addEventListener('fetch', event => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip external APIs and chrome-extension
|
||||
if (!url.origin.includes(self.location.origin) && !url.hostname.includes('googleapis') && !url.hostname.includes('gstatic')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(handleFetch(request));
|
||||
});
|
||||
|
||||
async function handleFetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
try {
|
||||
// Strategy 1: Core assets - Cache First
|
||||
if (CORE_ASSETS.some(asset => url.pathname === asset || url.pathname.endsWith(asset))) {
|
||||
return await cacheFirst(request, STATIC_CACHE);
|
||||
}
|
||||
|
||||
// Strategy 2: Dynamic assets - Stale While Revalidate
|
||||
if (DYNAMIC_ASSETS_PATTERNS.some(pattern => pattern.test(url.pathname))) {
|
||||
return await staleWhileRevalidate(request, DYNAMIC_CACHE);
|
||||
}
|
||||
|
||||
// Strategy 3: Fonts - Cache First with longer TTL
|
||||
if (url.hostname.includes('googleapis') || url.hostname.includes('gstatic')) {
|
||||
return await cacheFirst(request, DYNAMIC_CACHE);
|
||||
}
|
||||
|
||||
// Strategy 4: Everything else - Network First
|
||||
return await networkFirst(request, DYNAMIC_CACHE);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
|
||||
// Fallback for navigation requests
|
||||
if (request.mode === 'navigate') {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
return await cache.match('/index.html') || new Response('App offline', { status: 503 });
|
||||
}
|
||||
|
||||
return new Response('Resource not available offline', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
// Cache First strategy
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await fetch(request);
|
||||
if (response.status === 200) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Network First strategy
|
||||
async function networkFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.status === 200) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Stale While Revalidate strategy
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
// Always try to fetch and update cache in background
|
||||
const fetchPromise = fetch(request).then(response => {
|
||||
if (response.status === 200) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}).catch(() => null);
|
||||
|
||||
// Return cached version immediately if available, otherwise wait for network
|
||||
return cached || await fetchPromise;
|
||||
}
|
||||
|
||||
// Handle background sync for offline actions
|
||||
self.addEventListener('sync', event => {
|
||||
console.log('Background sync triggered:', event.tag);
|
||||
|
||||
if (event.tag === 'shader-save') {
|
||||
event.waitUntil(syncShaderData());
|
||||
}
|
||||
});
|
||||
|
||||
async function syncShaderData() {
|
||||
// This would handle syncing shader data when coming back online
|
||||
// For now, just log that sync is available
|
||||
console.log('Shader data sync available for future implementation');
|
||||
}
|
||||
|
||||
// Handle push notifications (for future features)
|
||||
self.addEventListener('push', event => {
|
||||
if (event.data) {
|
||||
const data = event.data.json();
|
||||
console.log('Push notification received:', data);
|
||||
|
||||
// Could be used for shader sharing notifications, etc.
|
||||
}
|
||||
});
|
||||
|
||||
// Provide offline status
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'GET_VERSION') {
|
||||
event.ports[0].postMessage({ version: CACHE_NAME });
|
||||
}
|
||||
});
|
||||
1206
src/FakeShader.ts
1206
src/FakeShader.ts
File diff suppressed because it is too large
Load Diff
281
src/RefactoredShader.ts
Normal file
281
src/RefactoredShader.ts
Normal file
@ -0,0 +1,281 @@
|
||||
import { WorkerMessage } from './shader/types';
|
||||
import { InputManager } from './shader/core/InputManager';
|
||||
import { WorkerPool } from './shader/core/WorkerPool';
|
||||
import { RenderController } from './shader/core/RenderController';
|
||||
|
||||
/**
|
||||
* Refactored shader renderer with separated concerns
|
||||
* Demonstrates the benefits of extracting responsibilities from the God class
|
||||
*/
|
||||
export class RefactoredShader {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private code: string;
|
||||
|
||||
// Extracted components with single responsibilities
|
||||
private inputManager: InputManager;
|
||||
private workerPool: WorkerPool;
|
||||
private renderController: RenderController;
|
||||
|
||||
// Render state
|
||||
private compiled: boolean = false;
|
||||
private renderMode: string = 'classic';
|
||||
private valueMode: string = 'integer';
|
||||
private hueShift: number = 0;
|
||||
|
||||
// Reusable message object for performance
|
||||
private reusableMessage: WorkerMessage = {
|
||||
id: '',
|
||||
type: 'render',
|
||||
width: 0,
|
||||
height: 0,
|
||||
fullWidth: 0,
|
||||
fullHeight: 0,
|
||||
time: 0,
|
||||
renderMode: 'classic',
|
||||
valueMode: 'integer',
|
||||
hueShift: 0,
|
||||
mouseX: 0,
|
||||
mouseY: 0,
|
||||
mousePressed: false,
|
||||
mouseVX: 0,
|
||||
mouseVY: 0,
|
||||
mouseClickTime: 0,
|
||||
touchCount: 0,
|
||||
touch0X: 0,
|
||||
touch0Y: 0,
|
||||
touch1X: 0,
|
||||
touch1Y: 0,
|
||||
pinchScale: 1,
|
||||
pinchRotation: 0,
|
||||
accelX: 0,
|
||||
accelY: 0,
|
||||
accelZ: 0,
|
||||
gyroX: 0,
|
||||
gyroY: 0,
|
||||
gyroZ: 0,
|
||||
audioLevel: 0,
|
||||
bassLevel: 0,
|
||||
midLevel: 0,
|
||||
trebleLevel: 0,
|
||||
bpm: 120,
|
||||
startY: 0,
|
||||
};
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, code: string = 'x^y') {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
this.code = code;
|
||||
|
||||
// Initialize separated components
|
||||
this.inputManager = new InputManager();
|
||||
this.workerPool = new WorkerPool();
|
||||
this.renderController = new RenderController();
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.compile();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// Set up render controller callback
|
||||
this.renderController.setRenderFrameHandler((time, renderId) => {
|
||||
this.render(renderId, time);
|
||||
});
|
||||
|
||||
// Set up worker pool callbacks
|
||||
this.workerPool.setRenderCompleteHandler((imageData) => {
|
||||
this.ctx.putImageData(imageData, 0, 0);
|
||||
this.renderController.setRenderingState(false);
|
||||
});
|
||||
|
||||
this.workerPool.setErrorHandler((error) => {
|
||||
console.error('Rendering error:', error);
|
||||
this.renderController.setRenderingState(false);
|
||||
});
|
||||
}
|
||||
|
||||
async compile(): Promise<void> {
|
||||
try {
|
||||
await this.workerPool.compile(this.code);
|
||||
this.compiled = true;
|
||||
console.log('Shader compiled successfully');
|
||||
} catch (error) {
|
||||
console.error('Compilation failed:', error);
|
||||
this.compiled = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private render(id: string, currentTime: number): void {
|
||||
if (!this.compiled || this.renderController.isCurrentlyRendering()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderController.setRenderingState(true);
|
||||
this.renderController.addPendingRender(id);
|
||||
|
||||
// Update reusable message to avoid allocations
|
||||
this.reusableMessage.id = id;
|
||||
this.reusableMessage.width = this.canvas.width;
|
||||
this.reusableMessage.height = this.canvas.height;
|
||||
this.reusableMessage.fullWidth = this.canvas.width;
|
||||
this.reusableMessage.fullHeight = this.canvas.height;
|
||||
this.reusableMessage.time = currentTime;
|
||||
this.reusableMessage.renderMode = this.renderMode;
|
||||
this.reusableMessage.valueMode = this.valueMode;
|
||||
this.reusableMessage.hueShift = this.hueShift;
|
||||
|
||||
// Populate input data from InputManager
|
||||
this.inputManager.populateWorkerMessage(this.reusableMessage);
|
||||
|
||||
// Choose rendering strategy based on worker count
|
||||
if (this.workerPool.getWorkerCount() > 1) {
|
||||
this.workerPool.renderMultiWorker(
|
||||
this.reusableMessage,
|
||||
this.canvas.width,
|
||||
this.canvas.height
|
||||
);
|
||||
} else {
|
||||
this.workerPool.renderSingleWorker(this.reusableMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
start(): void {
|
||||
if (!this.compiled) {
|
||||
console.warn('Cannot start rendering: shader not compiled');
|
||||
return;
|
||||
}
|
||||
this.renderController.start();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.renderController.stop();
|
||||
}
|
||||
|
||||
setCode(code: string): Promise<void> {
|
||||
this.code = code;
|
||||
this.compiled = false;
|
||||
return this.compile();
|
||||
}
|
||||
|
||||
setRenderMode(mode: string): void {
|
||||
this.renderMode = mode;
|
||||
}
|
||||
|
||||
setValueMode(mode: string): void {
|
||||
this.valueMode = mode;
|
||||
}
|
||||
|
||||
setHueShift(shift: number): void {
|
||||
this.hueShift = shift;
|
||||
}
|
||||
|
||||
setTargetFPS(fps: number): void {
|
||||
this.renderController.setTargetFPS(fps);
|
||||
}
|
||||
|
||||
setTimeSpeed(speed: number): void {
|
||||
this.renderController.setTimeSpeed(speed);
|
||||
}
|
||||
|
||||
// Input methods - delegated to InputManager
|
||||
setMousePosition(x: number, y: number): void {
|
||||
this.inputManager.setMousePosition(x, y);
|
||||
}
|
||||
|
||||
setMousePressed(pressed: boolean): void {
|
||||
this.inputManager.setMousePressed(pressed);
|
||||
}
|
||||
|
||||
setMouseVelocity(vx: number, vy: number): void {
|
||||
this.inputManager.setMouseVelocity(vx, vy);
|
||||
}
|
||||
|
||||
setTouchData(
|
||||
count: number,
|
||||
x0: number = 0,
|
||||
y0: number = 0,
|
||||
x1: number = 0,
|
||||
y1: number = 0,
|
||||
scale: number = 1,
|
||||
rotation: number = 0
|
||||
): void {
|
||||
this.inputManager.setTouchData(count, x0, y0, x1, y1, scale, rotation);
|
||||
}
|
||||
|
||||
setAccelerometer(x: number, y: number, z: number): void {
|
||||
this.inputManager.setAccelerometer(x, y, z);
|
||||
}
|
||||
|
||||
setGyroscope(x: number, y: number, z: number): void {
|
||||
this.inputManager.setGyroscope(x, y, z);
|
||||
}
|
||||
|
||||
setAudioLevels(
|
||||
level: number,
|
||||
bass: number,
|
||||
mid: number,
|
||||
treble: number,
|
||||
bpm: number
|
||||
): void {
|
||||
this.inputManager.setAudioLevels(level, bass, mid, treble, bpm);
|
||||
}
|
||||
|
||||
// Getters
|
||||
isCompiled(): boolean {
|
||||
return this.compiled;
|
||||
}
|
||||
|
||||
isAnimating(): boolean {
|
||||
return this.renderController.isAnimating();
|
||||
}
|
||||
|
||||
getCurrentTime(): number {
|
||||
return this.renderController.getCurrentTime();
|
||||
}
|
||||
|
||||
getFrameRate(): number {
|
||||
return this.renderController.getFrameRate();
|
||||
}
|
||||
|
||||
getWorkerCount(): number {
|
||||
return this.workerPool.getWorkerCount();
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
destroy(): void {
|
||||
this.renderController.stop();
|
||||
this.workerPool.destroy();
|
||||
}
|
||||
|
||||
// Helper methods for generating example shader code
|
||||
static getExamples(): string[] {
|
||||
return [
|
||||
'x^y',
|
||||
'x|y',
|
||||
'(x+y+t*10)%256',
|
||||
'((x>>4)^(y>>4))<<4',
|
||||
'(x^y^(x*y))%256',
|
||||
'd * t / 2.0',
|
||||
'((x&y)|(x^y))%256',
|
||||
'(x+y)&255',
|
||||
'x%y',
|
||||
'((x*t)^y)%256',
|
||||
'(x&(y|t*8))%256',
|
||||
'a+d*t',
|
||||
'n*t*400',
|
||||
'((x>>2)|(y<<2))%88',
|
||||
'(x*y*t)%256',
|
||||
'(x+y*t)%256',
|
||||
'(x^y^(t*16))%256',
|
||||
'((x*t)&(y*t))%256',
|
||||
'(x+(y<<(t%4)))%256',
|
||||
'((x*t%128)^y)%256',
|
||||
'(x^(y*t*2))%256',
|
||||
'((x+t)*(y+t))%256',
|
||||
'(x&y&(t*8))%256',
|
||||
'((x|t)^(y|t))%256',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,597 +0,0 @@
|
||||
// WebWorker for safe shader compilation and execution
|
||||
interface WorkerMessage {
|
||||
id: string;
|
||||
type: 'compile' | 'render';
|
||||
code?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
time?: number;
|
||||
renderMode?: string;
|
||||
startY?: number; // Y offset for tile rendering
|
||||
mouseX?: number;
|
||||
mouseY?: number;
|
||||
mousePressed?: boolean;
|
||||
mouseVX?: number;
|
||||
mouseVY?: number;
|
||||
mouseClickTime?: number;
|
||||
touchCount?: number;
|
||||
touch0X?: number;
|
||||
touch0Y?: number;
|
||||
touch1X?: number;
|
||||
touch1Y?: number;
|
||||
pinchScale?: number;
|
||||
pinchRotation?: number;
|
||||
accelX?: number;
|
||||
accelY?: number;
|
||||
accelZ?: number;
|
||||
gyroX?: number;
|
||||
gyroY?: number;
|
||||
gyroZ?: number;
|
||||
audioLevel?: number;
|
||||
bassLevel?: number;
|
||||
midLevel?: number;
|
||||
trebleLevel?: number;
|
||||
}
|
||||
|
||||
interface WorkerResponse {
|
||||
id: string;
|
||||
type: 'compiled' | 'rendered' | 'error';
|
||||
success: boolean;
|
||||
imageData?: ImageData;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class ShaderWorker {
|
||||
private compiledFunction: Function | null = null;
|
||||
private lastCode: string = '';
|
||||
private mathCache: Map<string, number> = new Map();
|
||||
private sinTable: Float32Array;
|
||||
private cosTable: Float32Array;
|
||||
private expTable: Float32Array;
|
||||
private logTable: Float32Array;
|
||||
private imageDataCache: Map<string, ImageData> = new Map();
|
||||
private compilationCache: Map<string, Function> = new Map();
|
||||
private colorTables: Map<string, Uint8Array> = new Map();
|
||||
|
||||
constructor() {
|
||||
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
|
||||
this.handleMessage(e.data);
|
||||
};
|
||||
|
||||
this.initializeLookupTables();
|
||||
this.initializeColorTables();
|
||||
}
|
||||
|
||||
private initializeLookupTables(): void {
|
||||
const tableSize = 4096;
|
||||
this.sinTable = new Float32Array(tableSize);
|
||||
this.cosTable = new Float32Array(tableSize);
|
||||
this.expTable = new Float32Array(tableSize);
|
||||
this.logTable = new Float32Array(tableSize);
|
||||
|
||||
for (let i = 0; i < tableSize; i++) {
|
||||
const x = (i / tableSize) * 2 * Math.PI;
|
||||
this.sinTable[i] = Math.sin(x);
|
||||
this.cosTable[i] = Math.cos(x);
|
||||
this.expTable[i] = Math.exp(x / tableSize);
|
||||
this.logTable[i] = Math.log(1 + x / tableSize);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeColorTables(): void {
|
||||
const tableSize = 256;
|
||||
|
||||
// Pre-compute color tables for each render mode
|
||||
const modes = ['classic', 'grayscale', 'red', 'green', 'blue', 'rgb', 'hsv', 'rainbow'];
|
||||
|
||||
for (const mode of modes) {
|
||||
const colorTable = new Uint8Array(tableSize * 3); // RGB triplets
|
||||
|
||||
for (let i = 0; i < tableSize; i++) {
|
||||
const [r, g, b] = this.calculateColorDirect(i, mode);
|
||||
colorTable[i * 3] = r;
|
||||
colorTable[i * 3 + 1] = g;
|
||||
colorTable[i * 3 + 2] = b;
|
||||
}
|
||||
|
||||
this.colorTables.set(mode, colorTable);
|
||||
}
|
||||
}
|
||||
|
||||
private fastSin(x: number): number {
|
||||
const index = Math.floor(Math.abs(x * this.sinTable.length / (2 * Math.PI)) % this.sinTable.length);
|
||||
return this.sinTable[index];
|
||||
}
|
||||
|
||||
private fastCos(x: number): number {
|
||||
const index = Math.floor(Math.abs(x * this.cosTable.length / (2 * Math.PI)) % this.cosTable.length);
|
||||
return this.cosTable[index];
|
||||
}
|
||||
|
||||
private handleMessage(message: WorkerMessage): void {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'compile':
|
||||
this.compileShader(message.id, message.code!);
|
||||
break;
|
||||
case 'render':
|
||||
this.renderShader(message.id, message.width!, message.height!, message.time!, message.renderMode || 'classic', message, message.startY || 0);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.postError(message.id, error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
private compileShader(id: string, code: string): void {
|
||||
const codeHash = this.hashCode(code);
|
||||
|
||||
if (code === this.lastCode && this.compiledFunction) {
|
||||
this.postMessage({ id, type: 'compiled', success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check compilation cache
|
||||
const cachedFunction = this.compilationCache.get(codeHash);
|
||||
if (cachedFunction) {
|
||||
this.compiledFunction = cachedFunction;
|
||||
this.lastCode = code;
|
||||
this.postMessage({ id, type: 'compiled', success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const safeCode = this.sanitizeCode(code);
|
||||
|
||||
// Check if expression is static (contains no variables)
|
||||
const isStatic = this.isStaticExpression(safeCode);
|
||||
|
||||
if (isStatic) {
|
||||
// Pre-compute static value
|
||||
const staticValue = this.evaluateStaticExpression(safeCode);
|
||||
this.compiledFunction = () => staticValue;
|
||||
} else {
|
||||
this.compiledFunction = new Function('x', 'y', 't', 'i', 'mouseX', 'mouseY', 'mousePressed', 'mouseVX', 'mouseVY', 'mouseClickTime', 'touchCount', 'touch0X', 'touch0Y', 'touch1X', 'touch1Y', 'pinchScale', 'pinchRotation', 'accelX', 'accelY', 'accelZ', 'gyroX', 'gyroY', 'gyroZ', 'audioLevel', 'bassLevel', 'midLevel', 'trebleLevel', `
|
||||
// Timeout protection
|
||||
const startTime = performance.now();
|
||||
let iterations = 0;
|
||||
|
||||
function checkTimeout() {
|
||||
iterations++;
|
||||
if (iterations % 1000 === 0 && performance.now() - startTime > 5) {
|
||||
throw new Error('Shader timeout');
|
||||
}
|
||||
}
|
||||
|
||||
return (function() {
|
||||
checkTimeout();
|
||||
return ${safeCode};
|
||||
})();
|
||||
`);
|
||||
}
|
||||
|
||||
// Cache the compiled function
|
||||
this.compilationCache.set(codeHash, this.compiledFunction);
|
||||
|
||||
// Limit cache size to prevent memory bloat
|
||||
if (this.compilationCache.size > 20) {
|
||||
const firstKey = this.compilationCache.keys().next().value;
|
||||
this.compilationCache.delete(firstKey);
|
||||
}
|
||||
|
||||
this.lastCode = code;
|
||||
this.postMessage({ id, type: 'compiled', success: true });
|
||||
} catch (error) {
|
||||
this.compiledFunction = null;
|
||||
this.postError(id, error instanceof Error ? error.message : 'Compilation failed');
|
||||
}
|
||||
}
|
||||
|
||||
private isStaticExpression(code: string): boolean {
|
||||
// Check if code contains any variables
|
||||
const variables = ['x', 'y', 't', 'i', 'mouseX', 'mouseY', 'mousePressed', 'mouseVX', 'mouseVY', 'mouseClickTime', 'touchCount', 'touch0X', 'touch0Y', 'touch1X', 'touch1Y', 'pinchScale', 'pinchRotation', 'accelX', 'accelY', 'accelZ', 'gyroX', 'gyroY', 'gyroZ', 'audioLevel', 'bassLevel', 'midLevel', 'trebleLevel'];
|
||||
|
||||
for (const variable of variables) {
|
||||
if (code.includes(variable)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private evaluateStaticExpression(code: string): number {
|
||||
try {
|
||||
// Safely evaluate numeric expression
|
||||
const result = new Function(`return ${code}`)();
|
||||
return isFinite(result) ? result : 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private hashCode(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
private renderShader(id: string, width: number, height: number, time: number, renderMode: string, message: WorkerMessage, startY: number = 0): void {
|
||||
if (!this.compiledFunction) {
|
||||
this.postError(id, 'No compiled shader');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = this.getOrCreateImageData(width, height);
|
||||
const data = imageData.data;
|
||||
const startTime = performance.now();
|
||||
const maxRenderTime = 50; // 50ms max render time
|
||||
|
||||
try {
|
||||
// Use tiled rendering for better timeout handling
|
||||
this.renderTiled(data, width, height, time, renderMode, message, startTime, maxRenderTime, startY);
|
||||
this.postMessage({ id, type: 'rendered', success: true, imageData });
|
||||
} catch (error) {
|
||||
this.postError(id, error instanceof Error ? error.message : 'Render failed');
|
||||
}
|
||||
}
|
||||
|
||||
private renderTiled(data: Uint8ClampedArray, width: number, height: number, time: number, renderMode: string, message: WorkerMessage, startTime: number, maxRenderTime: number, yOffset: number = 0): void {
|
||||
const tileSize = 64; // 64x64 tiles for better granularity
|
||||
const tilesX = Math.ceil(width / tileSize);
|
||||
const tilesY = Math.ceil(height / tileSize);
|
||||
|
||||
for (let tileY = 0; tileY < tilesY; tileY++) {
|
||||
for (let tileX = 0; tileX < tilesX; tileX++) {
|
||||
// Check timeout before each tile
|
||||
if (performance.now() - startTime > maxRenderTime) {
|
||||
const startX = tileX * tileSize;
|
||||
const startY = tileY * tileSize;
|
||||
this.fillRemainingPixels(data, width, height, startY, startX);
|
||||
return;
|
||||
}
|
||||
|
||||
const tileStartX = tileX * tileSize;
|
||||
const tileStartY = tileY * tileSize;
|
||||
const tileEndX = Math.min(tileStartX + tileSize, width);
|
||||
const tileEndY = Math.min(tileStartY + tileSize, height);
|
||||
|
||||
this.renderTile(data, width, tileStartX, tileStartY, tileEndX, tileEndY, time, renderMode, message, yOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderTile(data: Uint8ClampedArray, width: number, startX: number, startY: number, endX: number, endY: number, time: number, renderMode: string, message: WorkerMessage, yOffset: number = 0): void {
|
||||
for (let y = startY; y < endY; y++) {
|
||||
for (let x = startX; x < endX; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
const pixelIndex = y * width + x;
|
||||
|
||||
// Adjust y coordinate to account for tile offset
|
||||
const actualY = y + yOffset;
|
||||
|
||||
try {
|
||||
const value = this.compiledFunction!(
|
||||
x, actualY, time, pixelIndex,
|
||||
message.mouseX || 0, message.mouseY || 0,
|
||||
message.mousePressed || false,
|
||||
message.mouseVX || 0, message.mouseVY || 0,
|
||||
message.mouseClickTime || 0,
|
||||
message.touchCount || 0,
|
||||
message.touch0X || 0, message.touch0Y || 0,
|
||||
message.touch1X || 0, message.touch1Y || 0,
|
||||
message.pinchScale || 1, message.pinchRotation || 0,
|
||||
message.accelX || 0, message.accelY || 0, message.accelZ || 0,
|
||||
message.gyroX || 0, message.gyroY || 0, message.gyroZ || 0,
|
||||
message.audioLevel || 0, message.bassLevel || 0, message.midLevel || 0, message.trebleLevel || 0
|
||||
);
|
||||
const safeValue = isFinite(value) ? value : 0;
|
||||
const [r, g, b] = this.calculateColor(safeValue, renderMode);
|
||||
|
||||
data[i] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = 255;
|
||||
} catch (error) {
|
||||
data[i] = 0;
|
||||
data[i + 1] = 0;
|
||||
data[i + 2] = 0;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private canUseSIMD(): boolean {
|
||||
return typeof WebAssembly !== 'undefined' && WebAssembly.validate &&
|
||||
new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).every((byte, i) => byte === [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00][i]);
|
||||
}
|
||||
|
||||
private renderWithSIMD(data: Uint8ClampedArray, width: number, height: number, time: number, renderMode: string, message: WorkerMessage, startTime: number, maxRenderTime: number): void {
|
||||
const chunkSize = 4; // Process 4 pixels at once
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
if (performance.now() - startTime > maxRenderTime) {
|
||||
this.fillRemainingPixels(data, width, height, y, 0);
|
||||
break;
|
||||
}
|
||||
|
||||
for (let x = 0; x < width; x += chunkSize) {
|
||||
const endX = Math.min(x + chunkSize, width);
|
||||
const xValues = new Float32Array(chunkSize);
|
||||
const yValues = new Float32Array(chunkSize);
|
||||
const results = new Float32Array(chunkSize);
|
||||
|
||||
for (let i = 0; i < endX - x; i++) {
|
||||
xValues[i] = x + i;
|
||||
yValues[i] = y;
|
||||
}
|
||||
|
||||
this.computeChunkSIMD(xValues, yValues, results, endX - x, time, message);
|
||||
|
||||
for (let i = 0; i < endX - x; i++) {
|
||||
const pixelX = x + i;
|
||||
const pixelI = (y * width + pixelX) * 4;
|
||||
const safeValue = isFinite(results[i]) ? results[i] : 0;
|
||||
const [r, g, b] = this.calculateColor(safeValue, renderMode);
|
||||
|
||||
data[pixelI] = r;
|
||||
data[pixelI + 1] = g;
|
||||
data[pixelI + 2] = b;
|
||||
data[pixelI + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private computeChunkSIMD(xValues: Float32Array, yValues: Float32Array, results: Float32Array, count: number, time: number, message: WorkerMessage): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const pixelIndex = yValues[i] * (xValues.length / count) + xValues[i];
|
||||
results[i] = this.compiledFunction!(
|
||||
xValues[i], yValues[i], time, pixelIndex,
|
||||
message.mouseX || 0, message.mouseY || 0,
|
||||
message.mousePressed || false,
|
||||
message.mouseVX || 0, message.mouseVY || 0,
|
||||
message.mouseClickTime || 0,
|
||||
message.touchCount || 0,
|
||||
message.touch0X || 0, message.touch0Y || 0,
|
||||
message.touch1X || 0, message.touch1Y || 0,
|
||||
message.pinchScale || 1, message.pinchRotation || 0,
|
||||
message.accelX || 0, message.accelY || 0, message.accelZ || 0,
|
||||
message.gyroX || 0, message.gyroY || 0, message.gyroZ || 0,
|
||||
message.audioLevel || 0, message.bassLevel || 0, message.midLevel || 0, message.trebleLevel || 0
|
||||
);
|
||||
} catch (error) {
|
||||
results[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderSerial(data: Uint8ClampedArray, width: number, height: number, time: number, renderMode: string, message: WorkerMessage, startTime: number, maxRenderTime: number): void {
|
||||
for (let y = 0; y < height; y++) {
|
||||
if (performance.now() - startTime > maxRenderTime) {
|
||||
this.fillRemainingPixels(data, width, height, y, 0);
|
||||
break;
|
||||
}
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
const pixelIndex = y * width + x;
|
||||
|
||||
try {
|
||||
const value = this.compiledFunction!(
|
||||
x, y, time, pixelIndex,
|
||||
message.mouseX || 0, message.mouseY || 0,
|
||||
message.mousePressed || false,
|
||||
message.mouseVX || 0, message.mouseVY || 0,
|
||||
message.mouseClickTime || 0,
|
||||
message.touchCount || 0,
|
||||
message.touch0X || 0, message.touch0Y || 0,
|
||||
message.touch1X || 0, message.touch1Y || 0,
|
||||
message.pinchScale || 1, message.pinchRotation || 0,
|
||||
message.accelX || 0, message.accelY || 0, message.accelZ || 0,
|
||||
message.gyroX || 0, message.gyroY || 0, message.gyroZ || 0,
|
||||
message.audioLevel || 0, message.bassLevel || 0, message.midLevel || 0, message.trebleLevel || 0
|
||||
);
|
||||
const safeValue = isFinite(value) ? value : 0;
|
||||
const [r, g, b] = this.calculateColor(safeValue, renderMode);
|
||||
|
||||
data[i] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = 255;
|
||||
} catch (error) {
|
||||
data[i] = 0;
|
||||
data[i + 1] = 0;
|
||||
data[i + 2] = 0;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fillRemainingPixels(data: Uint8ClampedArray, width: number, height: number, startY: number, startX: number): void {
|
||||
for (let remainingY = startY; remainingY < height; remainingY++) {
|
||||
const xStart = remainingY === startY ? startX : 0;
|
||||
for (let remainingX = xStart; remainingX < width; remainingX++) {
|
||||
const i = (remainingY * width + remainingX) * 4;
|
||||
data[i] = 0;
|
||||
data[i + 1] = 0;
|
||||
data[i + 2] = 0;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateImageData(width: number, height: number): ImageData {
|
||||
const key = `${width}x${height}`;
|
||||
let imageData = this.imageDataCache.get(key);
|
||||
|
||||
if (!imageData) {
|
||||
imageData = new ImageData(width, height);
|
||||
this.imageDataCache.set(key, imageData);
|
||||
|
||||
// Limit cache size to prevent memory bloat
|
||||
if (this.imageDataCache.size > 5) {
|
||||
const firstKey = this.imageDataCache.keys().next().value;
|
||||
this.imageDataCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
private calculateColor(value: number, renderMode: string): [number, number, number] {
|
||||
const absValue = Math.abs(value) % 256;
|
||||
|
||||
// Use pre-computed color table if available
|
||||
const colorTable = this.colorTables.get(renderMode);
|
||||
if (colorTable) {
|
||||
const index = Math.floor(absValue) * 3;
|
||||
return [colorTable[index], colorTable[index + 1], colorTable[index + 2]];
|
||||
}
|
||||
|
||||
// Fallback to direct calculation
|
||||
return this.calculateColorDirect(absValue, renderMode);
|
||||
}
|
||||
|
||||
private calculateColorDirect(absValue: number, renderMode: string): [number, number, number] {
|
||||
switch (renderMode) {
|
||||
case 'classic':
|
||||
return [
|
||||
absValue,
|
||||
(absValue * 2) % 256,
|
||||
(absValue * 3) % 256
|
||||
];
|
||||
|
||||
case 'grayscale':
|
||||
return [absValue, absValue, absValue];
|
||||
|
||||
case 'red':
|
||||
return [absValue, 0, 0];
|
||||
|
||||
case 'green':
|
||||
return [0, absValue, 0];
|
||||
|
||||
case 'blue':
|
||||
return [0, 0, absValue];
|
||||
|
||||
case 'rgb':
|
||||
return [
|
||||
(absValue * 255 / 256) | 0,
|
||||
((absValue * 2) % 256 * 255 / 256) | 0,
|
||||
((absValue * 3) % 256 * 255 / 256) | 0
|
||||
];
|
||||
|
||||
case 'hsv':
|
||||
return this.hsvToRgb(absValue / 255.0, 1.0, 1.0);
|
||||
|
||||
case 'rainbow':
|
||||
return this.rainbowColor(absValue);
|
||||
|
||||
default:
|
||||
return [absValue, absValue, absValue];
|
||||
}
|
||||
}
|
||||
|
||||
private hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs((h * 6) % 2 - 1));
|
||||
const m = v - c;
|
||||
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (h < 1/6) {
|
||||
r = c; g = x; b = 0;
|
||||
} else if (h < 2/6) {
|
||||
r = x; g = c; b = 0;
|
||||
} else if (h < 3/6) {
|
||||
r = 0; g = c; b = x;
|
||||
} else if (h < 4/6) {
|
||||
r = 0; g = x; b = c;
|
||||
} else if (h < 5/6) {
|
||||
r = x; g = 0; b = c;
|
||||
} else {
|
||||
r = c; g = 0; b = x;
|
||||
}
|
||||
|
||||
return [
|
||||
Math.round((r + m) * 255),
|
||||
Math.round((g + m) * 255),
|
||||
Math.round((b + m) * 255)
|
||||
];
|
||||
}
|
||||
|
||||
private rainbowColor(value: number): [number, number, number] {
|
||||
const phase = (value / 255.0) * 6;
|
||||
const segment = Math.floor(phase);
|
||||
const remainder = phase - segment;
|
||||
const t = remainder;
|
||||
const q = 1 - t;
|
||||
|
||||
switch (segment % 6) {
|
||||
case 0: return [255, Math.round(t * 255), 0];
|
||||
case 1: return [Math.round(q * 255), 255, 0];
|
||||
case 2: return [0, 255, Math.round(t * 255)];
|
||||
case 3: return [0, Math.round(q * 255), 255];
|
||||
case 4: return [Math.round(t * 255), 0, 255];
|
||||
case 5: return [255, 0, Math.round(q * 255)];
|
||||
default: return [255, 255, 255];
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeCode(code: string): string {
|
||||
// Strict whitelist approach - extended to include new interaction variables
|
||||
// Variables: x, y, t, i, mouseX, mouseY, mousePressed, mouseVX, mouseVY, mouseClickTime, touchCount, touch0X, touch0Y, touch1X, touch1Y, pinchScale, pinchRotation, accelX, accelY, accelZ, gyroX, gyroY, gyroZ
|
||||
const allowedPattern = /^[0-9a-zA-Z\s\+\-\*\/\%\^\&\|\(\)\<\>\~\?:,\.xyti]+$/;
|
||||
|
||||
if (!allowedPattern.test(code)) {
|
||||
throw new Error('Invalid characters in shader code');
|
||||
}
|
||||
|
||||
// Check for dangerous keywords
|
||||
const dangerousKeywords = [
|
||||
'eval', 'Function', 'constructor', 'prototype', '__proto__',
|
||||
'window', 'document', 'global', 'process', 'require',
|
||||
'import', 'export', 'class', 'function', 'var', 'let', 'const',
|
||||
'while', 'for', 'do', 'if', 'else', 'switch', 'case', 'break',
|
||||
'continue', 'return', 'throw', 'try', 'catch', 'finally'
|
||||
];
|
||||
|
||||
const codeWords = code.toLowerCase().split(/[^a-z]/);
|
||||
for (const keyword of dangerousKeywords) {
|
||||
if (codeWords.includes(keyword)) {
|
||||
throw new Error(`Forbidden keyword: ${keyword}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Limit expression complexity
|
||||
const complexity = (code.match(/[\(\)]/g) || []).length;
|
||||
if (complexity > 20) {
|
||||
throw new Error('Expression too complex');
|
||||
}
|
||||
|
||||
// Limit code length
|
||||
if (code.length > 200) {
|
||||
throw new Error('Code too long');
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
private postMessage(response: WorkerResponse): void {
|
||||
self.postMessage(response);
|
||||
}
|
||||
|
||||
private postError(id: string, error: string): void {
|
||||
this.postMessage({ id, type: 'error', success: false, error });
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize worker
|
||||
new ShaderWorker();
|
||||
326
src/Storage.ts
326
src/Storage.ts
@ -1,162 +1,188 @@
|
||||
interface SavedShader {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
created: number;
|
||||
lastUsed: number;
|
||||
}
|
||||
import { AppSettings } from './stores/appSettings';
|
||||
import {
|
||||
STORAGE_KEYS,
|
||||
PERFORMANCE,
|
||||
DEFAULTS,
|
||||
FORMAT,
|
||||
ValueMode,
|
||||
} from './utils/constants';
|
||||
|
||||
interface AppSettings {
|
||||
resolution: number;
|
||||
fps: number;
|
||||
lastShaderCode: string;
|
||||
renderMode: string;
|
||||
uiOpacity?: number;
|
||||
export interface SavedShader {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
created: number;
|
||||
lastUsed: number;
|
||||
// Visual settings
|
||||
resolution?: number;
|
||||
fps?: number;
|
||||
renderMode?: string;
|
||||
valueMode?: ValueMode;
|
||||
uiOpacity?: number;
|
||||
hueShift?: number;
|
||||
}
|
||||
|
||||
export class Storage {
|
||||
private static readonly SHADERS_KEY = 'bitfielder_shaders';
|
||||
private static readonly SETTINGS_KEY = 'bitfielder_settings';
|
||||
|
||||
static saveShader(name: string, code: string): SavedShader {
|
||||
const shaders = this.getShaders();
|
||||
const id = this.generateId();
|
||||
const timestamp = Date.now();
|
||||
|
||||
const shader: SavedShader = {
|
||||
id,
|
||||
name: name.trim() || `Shader ${shaders.length + 1}`,
|
||||
code,
|
||||
created: timestamp,
|
||||
lastUsed: timestamp
|
||||
};
|
||||
|
||||
shaders.push(shader);
|
||||
this.setShaders(shaders);
|
||||
return shader;
|
||||
private static readonly SHADERS_KEY = STORAGE_KEYS.SHADERS;
|
||||
private static readonly SETTINGS_KEY = STORAGE_KEYS.SETTINGS;
|
||||
|
||||
static saveShader(
|
||||
name: string,
|
||||
code: string,
|
||||
settings?: Partial<AppSettings>
|
||||
): SavedShader {
|
||||
const shaders = this.getShaders();
|
||||
const id = this.generateId();
|
||||
const timestamp = Date.now();
|
||||
|
||||
const shader: SavedShader = {
|
||||
id,
|
||||
name: name.trim() || `Shader ${shaders.length + 1}`,
|
||||
code,
|
||||
created: timestamp,
|
||||
lastUsed: timestamp,
|
||||
// Include settings if provided
|
||||
...(settings && {
|
||||
resolution: settings.resolution,
|
||||
fps: settings.fps,
|
||||
renderMode: settings.renderMode,
|
||||
valueMode: settings.valueMode,
|
||||
uiOpacity: settings.uiOpacity,
|
||||
hueShift: settings.hueShift,
|
||||
}),
|
||||
};
|
||||
|
||||
shaders.push(shader);
|
||||
this.setShaders(shaders);
|
||||
return shader;
|
||||
}
|
||||
|
||||
static getShaders(): SavedShader[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.SHADERS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load shaders:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
static getShaders(): SavedShader[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.SHADERS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load shaders:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static deleteShader(id: string): void {
|
||||
const shaders = this.getShaders().filter((s) => s.id !== id);
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
|
||||
static updateShaderUsage(id: string): void {
|
||||
const shaders = this.getShaders();
|
||||
const shader = shaders.find((s) => s.id === id);
|
||||
if (shader) {
|
||||
shader.lastUsed = Date.now();
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
|
||||
static deleteShader(id: string): void {
|
||||
const shaders = this.getShaders().filter(s => s.id !== id);
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
|
||||
static renameShader(id: string, newName: string): void {
|
||||
const shaders = this.getShaders();
|
||||
const shader = shaders.find((s) => s.id === id);
|
||||
if (shader) {
|
||||
shader.name = newName.trim() || shader.name;
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
|
||||
static updateShaderUsage(id: string): void {
|
||||
const shaders = this.getShaders();
|
||||
const shader = shaders.find(s => s.id === id);
|
||||
if (shader) {
|
||||
shader.lastUsed = Date.now();
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
}
|
||||
|
||||
private static setShaders(shaders: SavedShader[]): void {
|
||||
try {
|
||||
// Keep only the most recent shaders
|
||||
const sortedShaders = shaders
|
||||
.sort((a, b) => b.lastUsed - a.lastUsed)
|
||||
.slice(0, PERFORMANCE.MAX_SAVED_SHADERS);
|
||||
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
|
||||
} catch (error) {
|
||||
console.error('Failed to save shaders:', error);
|
||||
}
|
||||
|
||||
static renameShader(id: string, newName: string): void {
|
||||
const shaders = this.getShaders();
|
||||
const shader = shaders.find(s => s.id === id);
|
||||
if (shader) {
|
||||
shader.name = newName.trim() || shader.name;
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
}
|
||||
|
||||
static saveSettings(settings: Partial<AppSettings>): void {
|
||||
try {
|
||||
const current = this.getSettings();
|
||||
const updated = { ...current, ...settings };
|
||||
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
|
||||
private static setShaders(shaders: SavedShader[]): void {
|
||||
try {
|
||||
// Keep only the 50 most recent shaders
|
||||
const sortedShaders = shaders
|
||||
.sort((a, b) => b.lastUsed - a.lastUsed)
|
||||
.slice(0, 50);
|
||||
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
|
||||
} catch (error) {
|
||||
console.error('Failed to save shaders:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static getSettings(): AppSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.SETTINGS_KEY);
|
||||
const defaults: AppSettings = {
|
||||
resolution: DEFAULTS.RESOLUTION,
|
||||
fps: DEFAULTS.FPS,
|
||||
lastShaderCode: DEFAULTS.SHADER_CODE,
|
||||
renderMode: DEFAULTS.RENDER_MODE,
|
||||
valueMode: DEFAULTS.VALUE_MODE,
|
||||
uiOpacity: DEFAULTS.UI_OPACITY,
|
||||
};
|
||||
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
return {
|
||||
resolution: DEFAULTS.RESOLUTION,
|
||||
fps: DEFAULTS.FPS,
|
||||
lastShaderCode: DEFAULTS.SHADER_CODE,
|
||||
renderMode: DEFAULTS.RENDER_MODE,
|
||||
valueMode: DEFAULTS.VALUE_MODE,
|
||||
uiOpacity: DEFAULTS.UI_OPACITY,
|
||||
};
|
||||
}
|
||||
|
||||
static saveSettings(settings: Partial<AppSettings>): void {
|
||||
try {
|
||||
const current = this.getSettings();
|
||||
const updated = { ...current, ...settings };
|
||||
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static clearAll(): void {
|
||||
try {
|
||||
localStorage.removeItem(this.SHADERS_KEY);
|
||||
localStorage.removeItem(this.SETTINGS_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear storage:', error);
|
||||
}
|
||||
|
||||
static getSettings(): AppSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.SETTINGS_KEY);
|
||||
const defaults: AppSettings = {
|
||||
resolution: 1,
|
||||
fps: 30,
|
||||
lastShaderCode: 'x^y',
|
||||
renderMode: 'classic',
|
||||
uiOpacity: 0.3
|
||||
};
|
||||
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
return {
|
||||
resolution: 1,
|
||||
fps: 30,
|
||||
lastShaderCode: 'x^y',
|
||||
renderMode: 'classic',
|
||||
uiOpacity: 0.3
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return Date.now().toString(FORMAT.ID_RADIX) + Math.random().toString(FORMAT.ID_RADIX).substr(FORMAT.ID_SUBSTRING_START);
|
||||
}
|
||||
|
||||
static exportShaders(): string {
|
||||
const shaders = this.getShaders();
|
||||
return JSON.stringify(shaders, null, FORMAT.JSON_INDENT);
|
||||
}
|
||||
|
||||
static importShaders(jsonData: string): boolean {
|
||||
try {
|
||||
const imported = JSON.parse(jsonData) as SavedShader[];
|
||||
if (!Array.isArray(imported)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
const valid = imported.every(
|
||||
(shader) =>
|
||||
shader.id &&
|
||||
shader.name &&
|
||||
shader.code &&
|
||||
typeof shader.created === 'number' &&
|
||||
typeof shader.lastUsed === 'number'
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = this.getShaders();
|
||||
const merged = [...existing, ...imported];
|
||||
this.setShaders(merged);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to import shaders:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
static clearAll(): void {
|
||||
try {
|
||||
localStorage.removeItem(this.SHADERS_KEY);
|
||||
localStorage.removeItem(this.SETTINGS_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
static exportShaders(): string {
|
||||
const shaders = this.getShaders();
|
||||
return JSON.stringify(shaders, null, 2);
|
||||
}
|
||||
|
||||
static importShaders(jsonData: string): boolean {
|
||||
try {
|
||||
const imported = JSON.parse(jsonData) as SavedShader[];
|
||||
if (!Array.isArray(imported)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
const valid = imported.every(shader =>
|
||||
shader.id && shader.name && shader.code &&
|
||||
typeof shader.created === 'number' &&
|
||||
typeof shader.lastUsed === 'number'
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = this.getShaders();
|
||||
const merged = [...existing, ...imported];
|
||||
this.setShaders(merged);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to import shaders:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
165
src/components/App.tsx
Normal file
165
src/components/App.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { TopBar } from './TopBar';
|
||||
import { MobileMenu } from './MobileMenu';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { ShaderLibrary } from './ShaderLibrary';
|
||||
import { HelpPopup } from './HelpPopup';
|
||||
import { WelcomePopup } from './WelcomePopup';
|
||||
import { ShaderCanvas } from './ShaderCanvas';
|
||||
import { PerformanceWarning } from './PerformanceWarning';
|
||||
import { uiState, showUI } from '../stores/ui';
|
||||
import { $appSettings, updateAppSettings, cycleValueMode, cycleRenderMode, handleTapTempo } from '../stores/appSettings';
|
||||
import { $shader } from '../stores/shader';
|
||||
import { loadShaders } from '../stores/library';
|
||||
import { Storage } from '../Storage';
|
||||
import { LucideIcon } from '../hooks/useLucideIcon';
|
||||
|
||||
export function App() {
|
||||
const ui = useStore(uiState);
|
||||
const settings = useStore($appSettings);
|
||||
const shader = useStore($shader);
|
||||
|
||||
useEffect(() => {
|
||||
// Load initial settings from storage
|
||||
const savedSettings = Storage.getSettings();
|
||||
$appSettings.set(savedSettings);
|
||||
|
||||
// Load saved shaders
|
||||
loadShaders();
|
||||
|
||||
// Set CSS custom property for UI opacity
|
||||
document.documentElement.style.setProperty(
|
||||
'--ui-opacity',
|
||||
(settings.uiOpacity ?? 0.3).toString()
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Update CSS custom property when opacity changes
|
||||
document.documentElement.style.setProperty(
|
||||
'--ui-opacity',
|
||||
(settings.uiOpacity ?? 0.3).toString()
|
||||
);
|
||||
}, [settings.uiOpacity]);
|
||||
|
||||
// Keyboard controls for hue shift and value mode when editor not focused
|
||||
useEffect(() => {
|
||||
let lastKeyTime = 0;
|
||||
const DEBOUNCE_DELAY = 150; // ms between key presses
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only activate if editor is not focused and no control/meta/alt keys are pressed
|
||||
const editorElement = document.getElementById('editor') as HTMLTextAreaElement;
|
||||
const isEditorFocused = editorElement && document.activeElement === editorElement;
|
||||
|
||||
if (isEditorFocused || e.ctrlKey || e.metaKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid key repeats
|
||||
const now = Date.now();
|
||||
if (now - lastKeyTime < DEBOUNCE_DELAY) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
lastKeyTime = now;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
// Decrease hue shift by 10 degrees (wrapping at 0)
|
||||
const currentHue = settings.hueShift ?? 0;
|
||||
const newHueLeft = currentHue - 10;
|
||||
updateAppSettings({ hueShift: newHueLeft < 0 ? 360 + newHueLeft : newHueLeft });
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
// Increase hue shift by 10 degrees (wrapping at 360)
|
||||
const currentHueRight = settings.hueShift ?? 0;
|
||||
const newHueRight = (currentHueRight + 10) % 360;
|
||||
updateAppSettings({ hueShift: newHueRight });
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
// Shift + Up: Cycle to previous render mode (color palette)
|
||||
cycleRenderMode('backward');
|
||||
} else {
|
||||
// Up: Cycle to previous value mode
|
||||
cycleValueMode('backward');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
// Shift + Down: Cycle to next render mode (color palette)
|
||||
cycleRenderMode('forward');
|
||||
} else {
|
||||
// Down: Cycle to next value mode
|
||||
cycleValueMode('forward');
|
||||
}
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
// Spacebar: Tap tempo to control time speed
|
||||
handleTapTempo();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [settings.hueShift]);
|
||||
|
||||
// Save settings changes to localStorage
|
||||
useEffect(() => {
|
||||
Storage.saveSettings({
|
||||
resolution: settings.resolution,
|
||||
fps: settings.fps,
|
||||
renderMode: settings.renderMode,
|
||||
valueMode: settings.valueMode,
|
||||
uiOpacity: settings.uiOpacity,
|
||||
hueShift: settings.hueShift,
|
||||
timeSpeed: settings.timeSpeed,
|
||||
lastShaderCode: shader.code,
|
||||
});
|
||||
}, [settings, shader.code]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShaderCanvas />
|
||||
|
||||
{ui.uiVisible ? (
|
||||
<>
|
||||
<TopBar />
|
||||
{!ui.mobileMenuOpen && <EditorPanel />}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
id="show-ui-btn"
|
||||
onClick={showUI}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<LucideIcon name="show" />
|
||||
</button>
|
||||
<EditorPanel minimal={true} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<MobileMenu />
|
||||
<ShaderLibrary />
|
||||
<HelpPopup />
|
||||
<WelcomePopup />
|
||||
<PerformanceWarning />
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/components/EditorPanel.tsx
Normal file
59
src/components/EditorPanel.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $shader, setShaderCode } from '../stores/shader';
|
||||
|
||||
interface EditorPanelProps {
|
||||
minimal?: boolean;
|
||||
}
|
||||
|
||||
export function EditorPanel({ minimal = false }: EditorPanelProps) {
|
||||
const shader = useStore($shader);
|
||||
// const ui = useStore(uiState); // Unused for now
|
||||
const [localCode, setLocalCode] = useState(shader.code);
|
||||
|
||||
// Check if code has changed from the compiled version
|
||||
const hasChanges = localCode !== shader.code;
|
||||
|
||||
const handleCodeChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
// Only update local state, don't compile until eval
|
||||
setLocalCode(e.target.value);
|
||||
};
|
||||
|
||||
// Sync local code when shader code changes externally (e.g., from library)
|
||||
useEffect(() => {
|
||||
setLocalCode(shader.code);
|
||||
}, [shader.code]);
|
||||
|
||||
const handleEval = () => {
|
||||
// Compile and render the shader
|
||||
setShaderCode(localCode);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleEval();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="editor-panel" className={minimal ? 'minimal' : ''}>
|
||||
<textarea
|
||||
id="editor"
|
||||
className={minimal ? 'minimal' : ''}
|
||||
value={localCode}
|
||||
onChange={handleCodeChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter shader code... (x, y, t, i, bpm, mouseX, mouseY, mousePressed, touchCount, accelX, audioLevel, bassLevel...)"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
id="eval-btn"
|
||||
className={minimal ? 'minimal' : ''}
|
||||
onClick={handleEval}
|
||||
>
|
||||
{hasChanges ? 'Eval *' : 'Eval'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
474
src/components/HelpPopup.tsx
Normal file
474
src/components/HelpPopup.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { uiState, hideHelp } from '../stores/ui';
|
||||
|
||||
export function HelpPopup() {
|
||||
const ui = useStore(uiState);
|
||||
const [valueModeExpanded, setValueModeExpanded] = useState(false);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
hideHelp();
|
||||
}
|
||||
};
|
||||
|
||||
if (!ui.helpPopupOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="help-popup"
|
||||
style={{ display: 'block' }}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<button className="close-btn" onClick={hideHelp}>
|
||||
×
|
||||
</button>
|
||||
<h3>Bitfielder Help</h3>
|
||||
|
||||
<div className="help-content">
|
||||
<div className="help-section">
|
||||
<h4>Keyboard Shortcuts</h4>
|
||||
<p>
|
||||
<strong>Ctrl+Enter</strong> - Execute shader code
|
||||
</p>
|
||||
<p>
|
||||
<strong>F11</strong> - Toggle fullscreen
|
||||
</p>
|
||||
<p>
|
||||
<strong>H</strong> - Hide/show UI
|
||||
</p>
|
||||
<p>
|
||||
<strong>R</strong> - Generate random shader
|
||||
</p>
|
||||
<p>
|
||||
<strong>G</strong> - Randomize visual settings (hue, modes)
|
||||
</p>
|
||||
<p>
|
||||
<strong>S</strong> - Share current shader (copy URL)
|
||||
</p>
|
||||
<p>
|
||||
<strong>?</strong> - Show this help
|
||||
</p>
|
||||
<p>
|
||||
<strong>M</strong> - Cycle value mode
|
||||
</p>
|
||||
<p>
|
||||
<strong>Space</strong> - Tap tempo
|
||||
</p>
|
||||
<p>
|
||||
<strong>Arrow Left/Right</strong> - Adjust hue shift
|
||||
</p>
|
||||
<p>
|
||||
<strong>Arrow Up/Down</strong> - Cycle value mode
|
||||
</p>
|
||||
<p>
|
||||
<strong>Shift+Arrow Up/Down</strong> - Cycle render mode
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Core Variables - Basics</h4>
|
||||
<p>
|
||||
<strong>x, y</strong> - Pixel coordinates
|
||||
</p>
|
||||
<p>
|
||||
<strong>t</strong> - Time (enables animation) - also available as t(n) for modulo wrapping
|
||||
</p>
|
||||
<p>
|
||||
<strong>bpm</strong> - Current BPM from tap tempo (default: 120)
|
||||
</p>
|
||||
<p>
|
||||
<strong>i</strong> - Pixel index
|
||||
</p>
|
||||
<p>
|
||||
<strong>r</strong> - Distance from center
|
||||
</p>
|
||||
<p>
|
||||
<strong>a</strong> - Angle from center (radians)
|
||||
</p>
|
||||
<p>
|
||||
<strong>u, v</strong> - Normalized coordinates (0.0 to 1.0)
|
||||
</p>
|
||||
<p>
|
||||
<strong>c</strong> - Normalized center distance (0.0 to 1.0)
|
||||
</p>
|
||||
<p>
|
||||
<strong>f</strong> - Frame count (discrete timing)
|
||||
</p>
|
||||
<p>
|
||||
<strong>d</strong> - Manhattan distance from center
|
||||
</p>
|
||||
<p>
|
||||
<strong>w, h</strong> - Canvas width and height (pixels)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Core Variables - Advanced</h4>
|
||||
<p>
|
||||
<strong>bx, by</strong> - Block coordinates (16-pixel chunks, great for pixelated effects)
|
||||
</p>
|
||||
<p>
|
||||
<strong>sx, sy</strong> - Signed coordinates (centered at origin, negative to positive)
|
||||
</p>
|
||||
<p>
|
||||
<strong>qx, qy</strong> - Quarter-block coordinates (8-pixel chunks, finer than bx/by)
|
||||
</p>
|
||||
<p>
|
||||
<strong>n</strong> - Noise value (0.0 to 1.0)
|
||||
</p>
|
||||
<p>
|
||||
<strong>p</strong> - Phase value (0 to 2π, cycles with time)
|
||||
</p>
|
||||
<p>
|
||||
<strong>z</strong> - Pseudo-depth coordinate (oscillates with distance and time)
|
||||
</p>
|
||||
<p>
|
||||
<strong>j</strong> - Per-pixel jitter/random value (0.0 to 1.0, deterministic)
|
||||
</p>
|
||||
<p>
|
||||
<strong>o</strong> - Oscillation value (wave function based on time and distance)
|
||||
</p>
|
||||
<p>
|
||||
<strong>g</strong> - Golden ratio constant (1.618... for natural spirals)
|
||||
</p>
|
||||
<p>
|
||||
<strong>mouseX, mouseY</strong> - Mouse position (0.0 to 1.0)
|
||||
</p>
|
||||
<p>
|
||||
<strong>mousePressed</strong> - Mouse button down (true/false)
|
||||
</p>
|
||||
<p>
|
||||
<strong>mouseVX, mouseVY</strong> - Mouse velocity
|
||||
</p>
|
||||
<p>
|
||||
<strong>mouseClickTime</strong> - Time since last click (ms)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Feedback Variables</h4>
|
||||
<p>
|
||||
<strong>b</strong> - Previous frame's luminance at this pixel (0-255)
|
||||
</p>
|
||||
<p>
|
||||
<strong>bn, bs, be, bw</strong> - Neighbor luminance (North, South, East, West)
|
||||
</p>
|
||||
<p>
|
||||
<strong>m</strong> - Momentum/velocity: Detects motion and change between frames
|
||||
</p>
|
||||
<p>
|
||||
<strong>l</strong> - Laplacian/diffusion: Creates natural spreading and heat diffusion
|
||||
</p>
|
||||
<p>
|
||||
<strong>k</strong> - Curvature/contrast: Edge detection and gradient magnitude
|
||||
</p>
|
||||
<p>
|
||||
<strong>s</strong> - State/memory: Persistent accumulator that remembers bright areas
|
||||
</p>
|
||||
<p>
|
||||
<strong>e</strong> - Echo/history: Temporal snapshots that recall past brightness patterns
|
||||
</p>
|
||||
<p>
|
||||
<em>Feedback uses actual displayed brightness with natural decay and frame-rate independence for stable, evolving patterns.</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Touch & Gestures</h4>
|
||||
<p>
|
||||
<strong>touchCount</strong> - Number of active touches
|
||||
</p>
|
||||
<p>
|
||||
<strong>touch0X, touch0Y</strong> - Primary touch position
|
||||
</p>
|
||||
<p>
|
||||
<strong>touch1X, touch1Y</strong> - Secondary touch position
|
||||
</p>
|
||||
<p>
|
||||
<strong>pinchScale</strong> - Pinch zoom scale factor
|
||||
</p>
|
||||
<p>
|
||||
<strong>pinchRotation</strong> - Pinch rotation angle
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Device Motion</h4>
|
||||
<p>
|
||||
<strong>accelX, accelY, accelZ</strong> - Accelerometer data
|
||||
</p>
|
||||
<p>
|
||||
<strong>gyroX, gyroY, gyroZ</strong> - Gyroscope rotation rates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Audio Reactive</h4>
|
||||
<p>
|
||||
<strong>audioLevel</strong> - Overall audio volume (0.0-1.0)
|
||||
</p>
|
||||
<p>
|
||||
<strong>bassLevel</strong> - Low frequencies (0.0-1.0)
|
||||
</p>
|
||||
<p>
|
||||
<strong>midLevel</strong> - Mid frequencies (0.0-1.0)
|
||||
</p>
|
||||
<p>
|
||||
<strong>trebleLevel</strong> - High frequencies (0.0-1.0)
|
||||
</p>
|
||||
<p>Click "Enable Audio" to activate microphone</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Operators</h4>
|
||||
<p>
|
||||
<strong>^ & |</strong> - XOR, AND, OR
|
||||
</p>
|
||||
<p>
|
||||
<strong><< >></strong> - Bit shift left/right
|
||||
</p>
|
||||
<p>
|
||||
<strong>+ - * / %</strong> - Math operations
|
||||
</p>
|
||||
<p>
|
||||
<strong>== != < ></strong> - Comparisons (return 0/1)
|
||||
</p>
|
||||
<p>
|
||||
<strong>? :</strong> - Ternary operator (condition ? true : false)
|
||||
</p>
|
||||
<p>
|
||||
<strong>~ **</strong> - Bitwise NOT, exponentiation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Math Functions</h4>
|
||||
<p>
|
||||
<strong>sin, cos, tan</strong> - Trigonometric functions
|
||||
</p>
|
||||
<p>
|
||||
<strong>asin, acos, atan, atan2</strong> - Inverse trigonometric functions
|
||||
</p>
|
||||
<p>
|
||||
<strong>abs, sqrt, cbrt, pow</strong> - Absolute, square root, cube root, power
|
||||
</p>
|
||||
<p>
|
||||
<strong>floor, ceil, round, trunc</strong> - Rounding functions
|
||||
</p>
|
||||
<p>
|
||||
<strong>min, max, sign</strong> - Minimum, maximum, sign (-1/0/1)
|
||||
</p>
|
||||
<p>
|
||||
<strong>log, log10, log2, exp</strong> - Logarithmic and exponential
|
||||
</p>
|
||||
<p>
|
||||
<strong>clamp(val, min, max)</strong> - Constrain value between min and max
|
||||
</p>
|
||||
<p>
|
||||
<strong>lerp(a, b, t)</strong> - Linear interpolation between a and b
|
||||
</p>
|
||||
<p>
|
||||
<strong>smooth(edge, x)</strong> - Smooth step function for gradients
|
||||
</p>
|
||||
<p>
|
||||
<strong>step(edge, x)</strong> - Step function (0 if x<edge, 1 otherwise)
|
||||
</p>
|
||||
<p>
|
||||
<strong>fract(x)</strong> - Fractional part (x - floor(x))
|
||||
</p>
|
||||
<p>
|
||||
<strong>mix(a, b, t)</strong> - Alias for lerp
|
||||
</p>
|
||||
<p>
|
||||
<strong>random</strong> - Random number 0-1
|
||||
</p>
|
||||
<p>
|
||||
<strong>PI, E</strong> - Math constants
|
||||
</p>
|
||||
<p>
|
||||
Use without Math. prefix: <code>sin(x)</code> not{' '}
|
||||
<code>Math.sin(x)</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Advanced Features</h4>
|
||||
<p>
|
||||
<strong>Array indexing:</strong> <code>[1,2,4,8][floor(t%4)]</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Complex expressions:</strong>{' '}
|
||||
<code>x>y ? sin(x) : cos(y)</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Nested functions:</strong>{' '}
|
||||
<code>pow(sin(x), abs(y-x))</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Logical operators:</strong> <code>x&&y</code>,{' '}
|
||||
<code>x||y</code>
|
||||
</p>
|
||||
<p>No character or length limits - use any JavaScript!</p>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<h4>Shader Library</h4>
|
||||
<p>
|
||||
<strong>Access:</strong> Hover over the left edge of the screen
|
||||
</p>
|
||||
<p>
|
||||
<strong>Save:</strong> Click the save icon to store current shader
|
||||
</p>
|
||||
<p>
|
||||
<strong>Search:</strong> Filter saved shaders by name
|
||||
</p>
|
||||
<p>
|
||||
<strong>Manage:</strong> Edit names or delete with the buttons
|
||||
</p>
|
||||
<p>
|
||||
<strong>Load:</strong> Click any shader to apply it instantly
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="help-section"
|
||||
style={{
|
||||
gridColumn: '1 / -1',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<h4
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onClick={() => setValueModeExpanded(!valueModeExpanded)}
|
||||
>
|
||||
Value Modes
|
||||
<span style={{ fontSize: '0.8em' }}>
|
||||
{valueModeExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</h4>
|
||||
{valueModeExpanded && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#000000',
|
||||
padding: '15px',
|
||||
marginTop: '10px',
|
||||
borderRadius: '0',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<strong>Integer:</strong> Traditional 0-255 mode with modulo wrapping
|
||||
</p>
|
||||
<p>
|
||||
<strong>Spiral:</strong> Logarithmic spirals with value-controlled tightness
|
||||
</p>
|
||||
<p>
|
||||
<strong>Float:</strong> Bitfield shader mode, clamps to 0-1, inverts, scales to 0-255
|
||||
</p>
|
||||
<p>
|
||||
<strong>Turbulence:</strong> Multi-octave turbulence with chaos control
|
||||
</p>
|
||||
<p>
|
||||
<strong>Polar:</strong> Spiral patterns combining angle and radius rotation
|
||||
</p>
|
||||
<p>
|
||||
<strong>Crystal:</strong> Crystalline lattice patterns on grid structure
|
||||
</p>
|
||||
<p>
|
||||
<strong>Distance:</strong> Concentric wave rings with variable frequency
|
||||
</p>
|
||||
<p>
|
||||
<strong>Marble:</strong> Marble-like veining with turbulent noise
|
||||
</p>
|
||||
<p>
|
||||
<strong>Wave:</strong> Multi-source interference with amplitude falloff
|
||||
</p>
|
||||
<p>
|
||||
<strong>Quantum:</strong> Quantum uncertainty probability distributions
|
||||
</p>
|
||||
<p>
|
||||
<strong>Fractal:</strong> Recursive patterns using multiple octaves of noise
|
||||
</p>
|
||||
<p>
|
||||
<strong>Logarithmic:</strong> Simple logarithmic scaling transformation
|
||||
</p>
|
||||
<p>
|
||||
<strong>Cellular:</strong> Cellular automata-inspired neighbor calculations
|
||||
</p>
|
||||
<p>
|
||||
<strong>Mirror:</strong> Kaleidoscope effects with symmetrical patterns
|
||||
</p>
|
||||
<p>
|
||||
<strong>Noise:</strong> Perlin-like noise using layered sine waves
|
||||
</p>
|
||||
<p>
|
||||
<strong>Rings:</strong> Concentric rings with controlled spacing
|
||||
</p>
|
||||
<p>
|
||||
<strong>Warp:</strong> Space deformation with barrel/lens distortion
|
||||
</p>
|
||||
<p>
|
||||
<strong>Mesh:</strong> Grid patterns with density and rotation control
|
||||
</p>
|
||||
<p>
|
||||
<strong>Flow:</strong> Fluid dynamics with flow sources and vortices
|
||||
</p>
|
||||
<p>
|
||||
<strong>Glitch:</strong> Digital corruption effects with bit manipulation
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="help-section"
|
||||
style={{
|
||||
gridColumn: '1 / -1',
|
||||
marginTop: '20px',
|
||||
textAlign: 'center',
|
||||
paddingTop: '20px',
|
||||
borderBottom: 'none',
|
||||
}}
|
||||
>
|
||||
<h4>About</h4>
|
||||
<p>
|
||||
<strong>Bitfielder</strong> - Interactive bitfield shader editor
|
||||
</p>
|
||||
<p>
|
||||
Created by <strong>BuboBubo</strong> (Raphaël Forment)
|
||||
</p>
|
||||
<p>
|
||||
Website:{' '}
|
||||
<a href="https://raphaelforment.fr" target="_blank" rel="noreferrer">
|
||||
raphaelforment.fr
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Source:{' '}
|
||||
<a
|
||||
href="https://git.raphaelforment.fr"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
git.raphaelforment.fr
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
License: <strong>AGPL 3.0</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
src/components/MobileMenu.tsx
Normal file
225
src/components/MobileMenu.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { uiState, closeMobileMenu, showHelp } from '../stores/ui';
|
||||
import { $appSettings, updateAppSettings } from '../stores/appSettings';
|
||||
import { VALUE_MODES, ValueMode } from '../utils/constants';
|
||||
import { $input } from '../stores/input';
|
||||
import { LucideIcon } from '../hooks/useLucideIcon';
|
||||
import { useAudio } from '../hooks/useAudio';
|
||||
|
||||
function getValueModeLabel(mode: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
integer: 'Integer (0-255)',
|
||||
float: 'Float (0.0-1.0)',
|
||||
polar: 'Polar (angle-based)',
|
||||
distance: 'Distance (radial)',
|
||||
wave: 'Wave (ripple)',
|
||||
fractal: 'Fractal (recursive)',
|
||||
cellular: 'Cellular (automata)',
|
||||
noise: 'Noise (perlin-like)',
|
||||
warp: 'Warp (space deformation)',
|
||||
flow: 'Flow (fluid dynamics)',
|
||||
spiral: 'Spiral (logarithmic)',
|
||||
turbulence: 'Turbulence (chaos)',
|
||||
crystal: 'Crystal (lattice)',
|
||||
marble: 'Marble (veining)',
|
||||
quantum: 'Quantum (uncertainty)',
|
||||
logarithmic: 'Logarithmic (scaling)',
|
||||
mirror: 'Mirror (symmetrical)',
|
||||
rings: 'Rings (interference)',
|
||||
mesh: 'Mesh (grid rotation)',
|
||||
glitch: 'Glitch (corruption)',
|
||||
};
|
||||
return labels[mode] || mode;
|
||||
}
|
||||
|
||||
export function MobileMenu() {
|
||||
const ui = useStore(uiState);
|
||||
const settings = useStore($appSettings);
|
||||
const input = useStore($input);
|
||||
const { setupAudio, disableAudio } = useAudio();
|
||||
|
||||
const handleFullscreen = () => {
|
||||
closeMobileMenu();
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
closeMobileMenu();
|
||||
// Implement share functionality
|
||||
};
|
||||
|
||||
const handleExportPNG = () => {
|
||||
closeMobileMenu();
|
||||
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
const link = document.createElement('a');
|
||||
link.download = `bitfielder-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioToggle = async () => {
|
||||
closeMobileMenu();
|
||||
if (input.audioEnabled) {
|
||||
disableAudio();
|
||||
} else {
|
||||
await setupAudio();
|
||||
}
|
||||
};
|
||||
|
||||
const handleHelp = () => {
|
||||
closeMobileMenu();
|
||||
showHelp();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id="mobile-menu-overlay"
|
||||
className={ui.mobileMenuOpen ? 'open' : ''}
|
||||
onClick={closeMobileMenu}
|
||||
/>
|
||||
|
||||
<div id="mobile-menu" className={ui.mobileMenuOpen ? 'open' : ''}>
|
||||
<div className="mobile-menu-section">
|
||||
<div className="mobile-menu-item">
|
||||
<label>Resolution</label>
|
||||
<select
|
||||
value={settings.resolution}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ resolution: parseInt(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="1">Full (1x)</option>
|
||||
<option value="2">Half (2x)</option>
|
||||
<option value="4">Quarter (4x)</option>
|
||||
<option value="8">Eighth (8x)</option>
|
||||
<option value="16">Sixteenth (16x)</option>
|
||||
<option value="32">Thirty-second (32x)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mobile-menu-item">
|
||||
<label>FPS</label>
|
||||
<select
|
||||
value={settings.fps}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ fps: parseInt(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="15">15 FPS</option>
|
||||
<option value="30">30 FPS</option>
|
||||
<option value="60">60 FPS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mobile-menu-item">
|
||||
<label>Value Mode</label>
|
||||
<select
|
||||
value={settings.valueMode}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ valueMode: e.target.value as ValueMode })
|
||||
}
|
||||
>
|
||||
{VALUE_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{getValueModeLabel(mode)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mobile-menu-item">
|
||||
<label>Render Mode</label>
|
||||
<select
|
||||
value={settings.renderMode}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ renderMode: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="grayscale">Grayscale</option>
|
||||
<option value="red">Red Channel</option>
|
||||
<option value="green">Green Channel</option>
|
||||
<option value="blue">Blue Channel</option>
|
||||
<option value="rainbow">Rainbow</option>
|
||||
<option value="thermal">Thermal</option>
|
||||
<option value="neon">Neon</option>
|
||||
<option value="sunset">Sunset</option>
|
||||
<option value="ocean">Ocean</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="copper">Copper</option>
|
||||
<option value="dithered">Dithered</option>
|
||||
<option value="palette">Palette</option>
|
||||
<option value="vintage">Vintage</option>
|
||||
<option value="infrared">Infrared</option>
|
||||
<option value="fire">Fire</option>
|
||||
<option value="ice">Ice</option>
|
||||
<option value="plasma">Plasma</option>
|
||||
<option value="xray">X-Ray</option>
|
||||
<option value="spectrum">Spectrum</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mobile-menu-item">
|
||||
<label>
|
||||
Hue Shift: {settings.hueShift ?? 0}°
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
value={settings.hueShift ?? 0}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ hueShift: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-menu-item">
|
||||
<label>
|
||||
UI Opacity: {Math.round((settings.uiOpacity ?? 0.3) * 100)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
value={Math.round((settings.uiOpacity ?? 0.3) * 100)}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ uiOpacity: parseInt(e.target.value) / 100 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-menu-section">
|
||||
<div className="mobile-menu-buttons">
|
||||
<button onClick={handleHelp}>
|
||||
<LucideIcon name="help" /> Help
|
||||
</button>
|
||||
<button onClick={handleFullscreen}>
|
||||
<LucideIcon name="fullscreen" /> Fullscreen
|
||||
</button>
|
||||
<button onClick={handleAudioToggle}>
|
||||
<LucideIcon
|
||||
name={input.audioEnabled ? 'microphone' : 'microphone-off'}
|
||||
/>
|
||||
{input.audioEnabled ? 'Disable Audio' : 'Enable Audio'}
|
||||
</button>
|
||||
<button onClick={handleShare}>
|
||||
<LucideIcon name="share" /> Share
|
||||
</button>
|
||||
<button onClick={handleExportPNG}>
|
||||
<LucideIcon name="export" /> Export PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
src/components/PerformanceWarning.tsx
Normal file
14
src/components/PerformanceWarning.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { uiState } from '../stores/ui';
|
||||
|
||||
export function PerformanceWarning() {
|
||||
const ui = useStore(uiState);
|
||||
|
||||
if (!ui.performanceWarningVisible) return null;
|
||||
|
||||
return (
|
||||
<div id="performance-warning" style={{ display: 'block' }}>
|
||||
Performance warning: Shader taking too long to render!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
src/components/ShaderCanvas.tsx
Normal file
324
src/components/ShaderCanvas.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $appSettings } from '../stores/appSettings';
|
||||
import { $shader } from '../stores/shader';
|
||||
import { uiState, showPerformanceWarning } from '../stores/ui';
|
||||
import {
|
||||
$input,
|
||||
updateMousePosition,
|
||||
updateTouchPosition,
|
||||
updateDeviceMotion,
|
||||
} from '../stores/input';
|
||||
import { FakeShader } from '../FakeShader';
|
||||
import { UI_HEIGHTS } from '../utils/constants';
|
||||
|
||||
export function ShaderCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const shaderRef = useRef<FakeShader | null>(null);
|
||||
const settings = useStore($appSettings);
|
||||
const shader = useStore($shader);
|
||||
const ui = useStore(uiState);
|
||||
const input = useStore($input);
|
||||
|
||||
// Mouse tracking state
|
||||
const mouseState = useRef({
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
// Touch gesture state
|
||||
const touchState = useRef({
|
||||
initialPinchDistance: 0,
|
||||
initialPinchAngle: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// Initialize shader
|
||||
shaderRef.current = new FakeShader(canvasRef.current, shader.code);
|
||||
|
||||
// Set up canvas size
|
||||
setupCanvas();
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
if (shaderRef.current) {
|
||||
shaderRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update shader when code changes
|
||||
useEffect(() => {
|
||||
if (shaderRef.current) {
|
||||
shaderRef.current.setCode(shader.code);
|
||||
}
|
||||
}, [shader.code]);
|
||||
|
||||
// Update shader settings
|
||||
useEffect(() => {
|
||||
if (shaderRef.current) {
|
||||
shaderRef.current.setRenderMode(settings.renderMode);
|
||||
shaderRef.current.setValueMode(settings.valueMode ?? 'integer');
|
||||
shaderRef.current.setHueShift(settings.hueShift ?? 0);
|
||||
shaderRef.current.setTimeSpeed(settings.timeSpeed ?? 1.0);
|
||||
shaderRef.current.setBPM(settings.currentBPM ?? 120);
|
||||
shaderRef.current.setTargetFPS(settings.fps);
|
||||
}
|
||||
}, [settings.renderMode, settings.valueMode, settings.hueShift, settings.timeSpeed, settings.currentBPM, settings.fps]);
|
||||
|
||||
// Handle canvas resize when resolution or UI visibility changes
|
||||
useEffect(() => {
|
||||
setupCanvas();
|
||||
}, [settings.resolution, ui.uiVisible]);
|
||||
|
||||
// Handle animation
|
||||
useEffect(() => {
|
||||
if (shaderRef.current) {
|
||||
shaderRef.current.startAnimation();
|
||||
}
|
||||
}, [shader.code]);
|
||||
|
||||
// Update input data to shader
|
||||
useEffect(() => {
|
||||
if (shaderRef.current) {
|
||||
shaderRef.current.setMousePosition(
|
||||
input.mouseX,
|
||||
input.mouseY,
|
||||
input.mousePressed,
|
||||
input.mouseVX,
|
||||
input.mouseVY,
|
||||
input.mouseClickTime
|
||||
);
|
||||
shaderRef.current.setTouchPosition(
|
||||
input.touchCount,
|
||||
input.touch0X,
|
||||
input.touch0Y,
|
||||
input.touch1X,
|
||||
input.touch1Y,
|
||||
input.pinchScale,
|
||||
input.pinchRotation
|
||||
);
|
||||
shaderRef.current.setDeviceMotion(
|
||||
input.accelX,
|
||||
input.accelY,
|
||||
input.accelZ,
|
||||
input.gyroX,
|
||||
input.gyroY,
|
||||
input.gyroZ
|
||||
);
|
||||
shaderRef.current.setAudioData(
|
||||
input.audioLevel,
|
||||
input.bassLevel,
|
||||
input.midLevel,
|
||||
input.trebleLevel
|
||||
);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const setupCanvas = () => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const width = window.innerWidth;
|
||||
const height = ui.uiVisible
|
||||
? window.innerHeight - UI_HEIGHTS.TOTAL_UI_HEIGHT
|
||||
: window.innerHeight;
|
||||
|
||||
const scale = settings.resolution;
|
||||
|
||||
// Set canvas internal size with resolution scaling
|
||||
canvasRef.current.width = Math.floor(width / scale);
|
||||
canvasRef.current.height = Math.floor(height / scale);
|
||||
|
||||
console.log(
|
||||
`Canvas setup: ${canvasRef.current.width}x${canvasRef.current.height} (scale: ${scale}x), UI visible: ${ui.uiVisible}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
const lastX = mouseState.current.lastX;
|
||||
const lastY = mouseState.current.lastY;
|
||||
const x = e.clientX / window.innerWidth;
|
||||
const y = 1.0 - e.clientY / window.innerHeight; // Invert Y to match shader coordinates
|
||||
const vx = x - lastX;
|
||||
const vy = y - lastY;
|
||||
|
||||
mouseState.current.lastX = x;
|
||||
mouseState.current.lastY = y;
|
||||
|
||||
updateMousePosition(x, y, input.mousePressed, vx, vy, input.mouseClickTime);
|
||||
};
|
||||
|
||||
const handleMouseDown = () => {
|
||||
const clickTime = Date.now();
|
||||
updateMousePosition(
|
||||
input.mouseX,
|
||||
input.mouseY,
|
||||
true,
|
||||
input.mouseVX,
|
||||
input.mouseVY,
|
||||
clickTime
|
||||
);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
updateMousePosition(
|
||||
input.mouseX,
|
||||
input.mouseY,
|
||||
false,
|
||||
input.mouseVX,
|
||||
input.mouseVY,
|
||||
input.mouseClickTime
|
||||
);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
// Only prevent default on canvas area for shader interaction
|
||||
e.preventDefault();
|
||||
|
||||
updateTouchPositions(e.touches);
|
||||
initializePinchGesture(e.touches);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
updateTouchPositions(e.touches);
|
||||
updatePinchGesture(e.touches);
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const touchCount = e.touches.length;
|
||||
if (touchCount === 0) {
|
||||
updateTouchPosition(0, 0, 0, 0, 0, 1, 0);
|
||||
} else {
|
||||
updateTouchPositions(e.touches);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTouchPositions = (touches: React.TouchList | TouchList) => {
|
||||
let touch0X = 0,
|
||||
touch0Y = 0,
|
||||
touch1X = 0,
|
||||
touch1Y = 0;
|
||||
|
||||
if (touches.length > 0) {
|
||||
touch0X = touches[0].clientX / window.innerWidth;
|
||||
touch0Y = 1.0 - touches[0].clientY / window.innerHeight;
|
||||
}
|
||||
if (touches.length > 1) {
|
||||
touch1X = touches[1].clientX / window.innerWidth;
|
||||
touch1Y = 1.0 - touches[1].clientY / window.innerHeight;
|
||||
}
|
||||
|
||||
updateTouchPosition(
|
||||
touches.length,
|
||||
touch0X,
|
||||
touch0Y,
|
||||
touch1X,
|
||||
touch1Y,
|
||||
input.pinchScale,
|
||||
input.pinchRotation
|
||||
);
|
||||
};
|
||||
|
||||
const initializePinchGesture = (touches: React.TouchList | TouchList) => {
|
||||
if (touches.length === 2) {
|
||||
const dx = touches[1].clientX - touches[0].clientX;
|
||||
const dy = touches[1].clientY - touches[0].clientY;
|
||||
touchState.current.initialPinchDistance = Math.sqrt(dx * dx + dy * dy);
|
||||
touchState.current.initialPinchAngle = Math.atan2(dy, dx);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePinchGesture = (touches: React.TouchList | TouchList) => {
|
||||
if (touches.length === 2 && touchState.current.initialPinchDistance > 0) {
|
||||
const dx = touches[1].clientX - touches[0].clientX;
|
||||
const dy = touches[1].clientY - touches[0].clientY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
const pinchScale = distance / touchState.current.initialPinchDistance;
|
||||
const pinchRotation = angle - touchState.current.initialPinchAngle;
|
||||
|
||||
updateTouchPosition(
|
||||
touches.length,
|
||||
input.touch0X,
|
||||
input.touch0Y,
|
||||
input.touch1X,
|
||||
input.touch1Y,
|
||||
pinchScale,
|
||||
pinchRotation
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up device motion listener
|
||||
useEffect(() => {
|
||||
const handleDeviceMotion = (e: DeviceMotionEvent) => {
|
||||
if (e.acceleration && e.rotationRate) {
|
||||
updateDeviceMotion(
|
||||
e.acceleration.x || 0,
|
||||
e.acceleration.y || 0,
|
||||
e.acceleration.z || 0,
|
||||
e.rotationRate.alpha || 0,
|
||||
e.rotationRate.beta || 0,
|
||||
e.rotationRate.gamma || 0
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (window.DeviceMotionEvent) {
|
||||
window.addEventListener('devicemotion', handleDeviceMotion);
|
||||
return () =>
|
||||
window.removeEventListener('devicemotion', handleDeviceMotion);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set up performance warning listener
|
||||
useEffect(() => {
|
||||
const handlePerformanceWarning = (e: MessageEvent) => {
|
||||
if (e.data === 'performance-warning') {
|
||||
showPerformanceWarning();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handlePerformanceWarning);
|
||||
return () =>
|
||||
window.removeEventListener('message', handlePerformanceWarning);
|
||||
}, []);
|
||||
|
||||
// Set up window resize listener
|
||||
useEffect(() => {
|
||||
const handleResize = () => setupCanvas();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [settings.resolution, ui.uiVisible]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
id="canvas"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
imageRendering: 'pixelated',
|
||||
touchAction: 'none',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
249
src/components/ShaderLibrary.tsx
Normal file
249
src/components/ShaderLibrary.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { uiState, toggleShaderLibrary } from '../stores/ui';
|
||||
import {
|
||||
$library,
|
||||
setSearchTerm,
|
||||
getFilteredShaders,
|
||||
saveShader,
|
||||
deleteShader,
|
||||
renameShader,
|
||||
updateShaderUsage,
|
||||
} from '../stores/library';
|
||||
import { $shader, setShaderCode } from '../stores/shader';
|
||||
import { $appSettings, updateAppSettings } from '../stores/appSettings';
|
||||
|
||||
export function ShaderLibrary() {
|
||||
const ui = useStore(uiState);
|
||||
const library = useStore($library);
|
||||
const shader = useStore($shader);
|
||||
const settings = useStore($appSettings);
|
||||
const [shaderName, setShaderName] = useState('');
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
const filteredShaders = getFilteredShaders();
|
||||
|
||||
const handleSaveShader = () => {
|
||||
const name = shaderName.trim();
|
||||
const code = shader.code.trim();
|
||||
|
||||
if (!code) return;
|
||||
|
||||
const currentSettings = {
|
||||
resolution: settings.resolution,
|
||||
fps: settings.fps,
|
||||
renderMode: settings.renderMode,
|
||||
valueMode: settings.valueMode,
|
||||
uiOpacity: settings.uiOpacity,
|
||||
hueShift: settings.hueShift,
|
||||
};
|
||||
|
||||
saveShader(name, code, currentSettings);
|
||||
setShaderName('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveShader();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadShader = (shaderId: string) => {
|
||||
const shaderData = library.shaders.find((s) => s.id === shaderId);
|
||||
if (shaderData) {
|
||||
// Load the code
|
||||
setShaderCode(shaderData.code);
|
||||
|
||||
// Apply saved settings if they exist
|
||||
const newSettings: any = {};
|
||||
if (shaderData.resolution) newSettings.resolution = shaderData.resolution;
|
||||
if (shaderData.fps) newSettings.fps = shaderData.fps;
|
||||
if (shaderData.renderMode) newSettings.renderMode = shaderData.renderMode;
|
||||
if (shaderData.valueMode) newSettings.valueMode = shaderData.valueMode;
|
||||
if (shaderData.uiOpacity !== undefined)
|
||||
newSettings.uiOpacity = shaderData.uiOpacity;
|
||||
if (shaderData.hueShift !== undefined)
|
||||
newSettings.hueShift = shaderData.hueShift;
|
||||
|
||||
if (Object.keys(newSettings).length > 0) {
|
||||
updateAppSettings(newSettings);
|
||||
}
|
||||
|
||||
updateShaderUsage(shaderId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteShader = (shaderId: string) => {
|
||||
deleteShader(shaderId);
|
||||
};
|
||||
|
||||
const startRename = (shaderId: string) => {
|
||||
const shaderData = library.shaders.find((s) => s.id === shaderId);
|
||||
if (shaderData) {
|
||||
setRenamingId(shaderId);
|
||||
setRenameValue(shaderData.name);
|
||||
}
|
||||
};
|
||||
|
||||
const finishRename = () => {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
renameShader(renamingId, renameValue.trim());
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
};
|
||||
|
||||
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
finishRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}
|
||||
};
|
||||
|
||||
// const escapeHtml = (text: string): string => { // Unused for now
|
||||
// const div = document.createElement('div');
|
||||
// div.textContent = text;
|
||||
// return div.innerHTML;
|
||||
// };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id="shader-library-trigger"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '40px',
|
||||
left: '0',
|
||||
width: '20px',
|
||||
height: 'calc(100vh - 40px)',
|
||||
zIndex: 91,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={toggleShaderLibrary}
|
||||
></div>
|
||||
<div id="shader-library" className={ui.shaderLibraryOpen ? 'open' : ''}>
|
||||
<div className="library-header">
|
||||
<h3>Shader Library</h3>
|
||||
<div className="save-shader">
|
||||
<input
|
||||
type="text"
|
||||
value={shaderName}
|
||||
onChange={(e) => setShaderName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Shader name..."
|
||||
maxLength={30}
|
||||
/>
|
||||
<button onClick={handleSaveShader}>Save</button>
|
||||
</div>
|
||||
<div className="search-shader">
|
||||
<input
|
||||
type="text"
|
||||
value={library.searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search shaders..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shader-list">
|
||||
{filteredShaders.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
{library.searchTerm
|
||||
? 'No shaders match your search'
|
||||
: 'No saved shaders'}
|
||||
</div>
|
||||
) : (
|
||||
filteredShaders.map((shaderData) => {
|
||||
const hasSettings =
|
||||
shaderData.resolution ||
|
||||
shaderData.fps ||
|
||||
shaderData.renderMode ||
|
||||
shaderData.valueMode ||
|
||||
shaderData.uiOpacity !== undefined;
|
||||
const settingsIndicator = hasSettings ? ' ⚙' : '';
|
||||
|
||||
return (
|
||||
<div key={shaderData.id} className="shader-item">
|
||||
<div
|
||||
className="shader-item-header"
|
||||
onClick={() => handleLoadShader(shaderData.id)}
|
||||
>
|
||||
<span className="shader-name">
|
||||
{renamingId === shaderData.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={finishRename}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
autoFocus
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid #666',
|
||||
color: '#fff',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '2px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{shaderData.name}
|
||||
{hasSettings && (
|
||||
<span
|
||||
style={{ color: '#4A9EFF', fontSize: '10px' }}
|
||||
title="Includes visual settings"
|
||||
>
|
||||
{settingsIndicator}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="shader-actions">
|
||||
<button
|
||||
className="shader-action rename"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(shaderData.id);
|
||||
}}
|
||||
title="Rename"
|
||||
>
|
||||
edit
|
||||
</button>
|
||||
<button
|
||||
className="shader-action delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteShader(shaderData.id);
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
del
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shader-code">{shaderData.code}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
350
src/components/TopBar.tsx
Normal file
350
src/components/TopBar.tsx
Normal file
@ -0,0 +1,350 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useState } from 'react';
|
||||
import { $appSettings, updateAppSettings } from '../stores/appSettings';
|
||||
import { VALUE_MODES, ValueMode, RENDER_MODES } from '../utils/constants';
|
||||
import {
|
||||
uiState,
|
||||
toggleMobileMenu,
|
||||
showHelp,
|
||||
toggleUI,
|
||||
toggleShaderLibrary,
|
||||
} from '../stores/ui';
|
||||
import { $shader, setShaderCode } from '../stores/shader';
|
||||
import { $input } from '../stores/input';
|
||||
import { FakeShader } from '../FakeShader';
|
||||
import { useAudio } from '../hooks/useAudio';
|
||||
import { LucideIcon } from '../hooks/useLucideIcon';
|
||||
|
||||
function getValueModeLabel(mode: string): string {
|
||||
// Automatically generate human-readable labels from mode names
|
||||
return mode.charAt(0).toUpperCase() + mode.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function getRenderModeLabel(mode: string): string {
|
||||
// Automatically generate human-readable labels from render mode names
|
||||
return mode.charAt(0).toUpperCase() + mode.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
const settings = useStore($appSettings);
|
||||
const ui = useStore(uiState);
|
||||
const shader = useStore($shader);
|
||||
const input = useStore($input);
|
||||
const { setupAudio, disableAudio } = useAudio();
|
||||
const [shareStatus, setShareStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
|
||||
|
||||
const handleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRandom = () => {
|
||||
const randomCode = FakeShader.generateRandomCode();
|
||||
setShaderCode(randomCode);
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
const shareData = {
|
||||
code: shader.code,
|
||||
resolution: settings.resolution,
|
||||
fps: settings.fps,
|
||||
renderMode: settings.renderMode,
|
||||
valueMode: settings.valueMode,
|
||||
uiOpacity: settings.uiOpacity,
|
||||
hueShift: settings.hueShift,
|
||||
};
|
||||
|
||||
try {
|
||||
const encoded = btoa(JSON.stringify(shareData));
|
||||
const url = `${window.location.origin}${window.location.pathname}#${encoded}`;
|
||||
|
||||
console.log('Sharing URL:', url);
|
||||
console.log('Share data:', shareData);
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
console.log('URL copied to clipboard');
|
||||
setShareStatus('copied');
|
||||
setTimeout(() => setShareStatus('idle'), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
console.log('Copy failed');
|
||||
setShareStatus('failed');
|
||||
setTimeout(() => setShareStatus('idle'), 2000);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create share URL:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPNG = () => {
|
||||
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
const link = document.createElement('a');
|
||||
link.download = `bitfielder-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioToggle = async () => {
|
||||
if (input.audioEnabled) {
|
||||
disableAudio();
|
||||
} else {
|
||||
await setupAudio();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="topbar"
|
||||
className={ui.uiVisible ? '' : 'hidden'}>
|
||||
<div className="title">Bitfielder</div>
|
||||
<div className="controls">
|
||||
<div className="controls-desktop">
|
||||
<select
|
||||
value={settings.resolution}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ resolution: parseInt(e.target.value) })
|
||||
}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid #555',
|
||||
color: '#fff',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
marginRight: '10px',
|
||||
}}
|
||||
>
|
||||
<option value="1">Full (1x)</option>
|
||||
<option value="2">Half (2x)</option>
|
||||
<option value="4">Quarter (4x)</option>
|
||||
<option value="8">Eighth (8x)</option>
|
||||
<option value="16">Sixteenth (16x)</option>
|
||||
<option value="32">Thirty-second (32x)</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={settings.fps}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ fps: parseInt(e.target.value) })
|
||||
}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid #555',
|
||||
color: '#fff',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
marginRight: '10px',
|
||||
}}
|
||||
>
|
||||
<option value="15">15 FPS</option>
|
||||
<option value="30">30 FPS</option>
|
||||
<option value="60">60 FPS</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={settings.valueMode}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ valueMode: e.target.value as ValueMode })
|
||||
}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid #555',
|
||||
color: '#fff',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
marginRight: '10px',
|
||||
}}
|
||||
>
|
||||
{VALUE_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{getValueModeLabel(mode)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={settings.renderMode}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ renderMode: e.target.value })
|
||||
}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid #555',
|
||||
color: '#fff',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
marginRight: '10px',
|
||||
}}
|
||||
>
|
||||
{RENDER_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{getRenderModeLabel(mode)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
value={settings.hueShift ?? 0}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ hueShift: parseInt(e.target.value) })
|
||||
}
|
||||
style={{ width: '80px', verticalAlign: 'middle', marginRight: '10px' }}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="100"
|
||||
value={Math.round((settings.uiOpacity ?? 0.3) * 100)}
|
||||
onChange={(e) =>
|
||||
updateAppSettings({ uiOpacity: parseInt(e.target.value) / 100 })
|
||||
}
|
||||
style={{ width: '80px', verticalAlign: 'middle', marginRight: '10px' }}
|
||||
/>
|
||||
|
||||
<button id="help-btn" onClick={showHelp}>
|
||||
<LucideIcon name="help" />
|
||||
</button>
|
||||
<button id="fullscreen-btn" onClick={handleFullscreen}>
|
||||
<LucideIcon name="fullscreen" />
|
||||
</button>
|
||||
<button id="hide-ui-btn" onClick={toggleUI}>
|
||||
<LucideIcon name="hide" />
|
||||
</button>
|
||||
<button id="random-btn" onClick={handleRandom}>
|
||||
<LucideIcon name="random" />
|
||||
</button>
|
||||
<button id="audio-btn" onClick={handleAudioToggle}>
|
||||
<LucideIcon
|
||||
name={input.audioEnabled ? 'microphone' : 'microphone-off'}
|
||||
/>
|
||||
</button>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
id="share-btn"
|
||||
onClick={handleShare}
|
||||
style={{
|
||||
background: shareStatus === 'copied' ? '#4CAF50' : shareStatus === 'failed' ? '#f44336' : undefined,
|
||||
color: shareStatus !== 'idle' ? '#fff' : undefined,
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<LucideIcon name={shareStatus === 'copied' ? 'check' : 'share'} />
|
||||
</button>
|
||||
{shareStatus === 'copied' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 1000,
|
||||
marginTop: '5px',
|
||||
animation: 'fadeIn 0.3s ease'
|
||||
}}
|
||||
>
|
||||
Link copied to clipboard!
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-5px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '0',
|
||||
height: '0',
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderBottom: '5px solid #333'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{shareStatus === 'failed' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: '#f44336',
|
||||
color: '#fff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 1000,
|
||||
marginTop: '5px',
|
||||
animation: 'fadeIn 0.3s ease'
|
||||
}}
|
||||
>
|
||||
Failed to copy link
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-5px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '0',
|
||||
height: '0',
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderBottom: '5px solid #f44336'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button id="export-png-btn" onClick={handleExportPNG}>
|
||||
<LucideIcon name="export" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="controls-mobile">
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="Shader Library"
|
||||
onClick={toggleShaderLibrary}
|
||||
>
|
||||
<LucideIcon name="library" />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="Random"
|
||||
onClick={handleRandom}
|
||||
>
|
||||
<LucideIcon name="random" />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="Hide UI"
|
||||
onClick={toggleUI}
|
||||
>
|
||||
<LucideIcon name="hide" />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="Menu"
|
||||
onClick={toggleMobileMenu}
|
||||
>
|
||||
<LucideIcon name={ui.mobileMenuOpen ? 'close' : 'menu'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/WelcomePopup.tsx
Normal file
86
src/components/WelcomePopup.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { uiState, hideWelcome } from '../stores/ui';
|
||||
|
||||
export const WelcomePopup: React.FC = () => {
|
||||
const ui = useStore(uiState);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
hideWelcome();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = () => {
|
||||
hideWelcome();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ui.welcomePopupOpen) {
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}
|
||||
}, [ui.welcomePopupOpen]);
|
||||
|
||||
if (!ui.welcomePopupOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="welcome-popup" onClick={handleBackdropClick}>
|
||||
<div className="welcome-popup-content">
|
||||
<h2 className="welcome-title">Welcome to BitFielder</h2>
|
||||
|
||||
<div className="welcome-content">
|
||||
<p style={{ textAlign: 'justify' }}>
|
||||
BitFielder is a bitfield shader editor made by{' '}
|
||||
<a href="https://raphaelforment.fr">BuboBubo</a>. Use it to live code weird reactive visuals.
|
||||
I built this tool to have fun and explore. It is quite easy to learn and quite rewarding if you
|
||||
put time into it! This tool can eat a lot of CPU, beware!{' '}
|
||||
</p>
|
||||
|
||||
<h3>Getting Started</h3>
|
||||
<ul>
|
||||
<li>Edit the shader code and press <i>Eval</i> or <i>Ctrl+Enter</i>
|
||||
<ul style={{ marginTop: '8px' }}>
|
||||
<li><b>Lazy mode:</b> press <i>R</i>, <i>G</i> and <i>M</i> randomly!</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Use special variables to create reactive effects</li>
|
||||
<li>Learn more variables / operators / functions</li>
|
||||
<li>Store shaders in the library (left pane)</li>
|
||||
<li>Export your creations as images or sharable links</li>
|
||||
</ul>
|
||||
|
||||
<h3>Key Features</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Realtime editing:</strong> See your changes instantly
|
||||
</li>
|
||||
<li>
|
||||
<strong>Motion and touch:</strong> Mouse, touchscreen support
|
||||
</li>
|
||||
<li>
|
||||
<strong>Audio reactive:</strong> Synchronize with a sound source
|
||||
</li>
|
||||
<li>
|
||||
<strong>Randomly cool:</strong> Pretty much any formula works!
|
||||
<ul style={{ marginTop: '8px' }}><li>Extra keybindings for fun.</li></ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>Export capabilities:</strong> Save and share your work
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="help-hint">
|
||||
Press <kbd>?</kbd> anytime to view keyboard shortcuts and detailed
|
||||
help.
|
||||
</p>
|
||||
|
||||
<p className="dismiss-hint">Press any key to dismiss this message</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
118
src/hooks/useAudio.ts
Normal file
118
src/hooks/useAudio.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { updateAudioData, setAudioEnabled } from '../stores/input';
|
||||
|
||||
export function useAudio() {
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
const setupAudio = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
if (!window.AudioContext && !(window as any).webkitAudioContext) {
|
||||
console.warn('Web Audio API not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
audioContextRef.current = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
analyserRef.current = audioContextRef.current.createAnalyser();
|
||||
analyserRef.current.fftSize = 256;
|
||||
analyserRef.current.smoothingTimeConstant = 0.8;
|
||||
|
||||
microphoneRef.current =
|
||||
audioContextRef.current.createMediaStreamSource(stream);
|
||||
microphoneRef.current.connect(analyserRef.current);
|
||||
|
||||
setAudioEnabled(true);
|
||||
startAudioAnalysis();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to setup audio:', error);
|
||||
setAudioEnabled(false);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disableAudio = useCallback(() => {
|
||||
setAudioEnabled(false);
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
|
||||
if (microphoneRef.current) {
|
||||
microphoneRef.current.disconnect();
|
||||
microphoneRef.current = null;
|
||||
}
|
||||
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
analyserRef.current = null;
|
||||
|
||||
// Reset audio levels
|
||||
updateAudioData(0, 0, 0, 0);
|
||||
}, []);
|
||||
|
||||
const startAudioAnalysis = useCallback(() => {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
const bufferLength = analyserRef.current.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
const analyze = () => {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
// Calculate overall audio level (RMS)
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
sum += dataArray[i] * dataArray[i];
|
||||
}
|
||||
const audioLevel = Math.sqrt(sum / bufferLength) / 255;
|
||||
|
||||
// Calculate frequency bands
|
||||
const lowEnd = Math.floor(bufferLength * 0.08);
|
||||
const midEnd = Math.floor(bufferLength * 0.67);
|
||||
|
||||
// Bass (low frequencies)
|
||||
let bassSum = 0;
|
||||
for (let i = 0; i < lowEnd; i++) {
|
||||
bassSum += dataArray[i];
|
||||
}
|
||||
const bassLevel = bassSum / lowEnd / 255;
|
||||
|
||||
// Mid frequencies
|
||||
let midSum = 0;
|
||||
for (let i = lowEnd; i < midEnd; i++) {
|
||||
midSum += dataArray[i];
|
||||
}
|
||||
const midLevel = midSum / (midEnd - lowEnd) / 255;
|
||||
|
||||
// Treble (high frequencies)
|
||||
let trebleSum = 0;
|
||||
for (let i = midEnd; i < bufferLength; i++) {
|
||||
trebleSum += dataArray[i];
|
||||
}
|
||||
const trebleLevel = trebleSum / (bufferLength - midEnd) / 255;
|
||||
|
||||
updateAudioData(audioLevel, bassLevel, midLevel, trebleLevel);
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(analyze);
|
||||
};
|
||||
|
||||
analyze();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
setupAudio,
|
||||
disableAudio,
|
||||
};
|
||||
}
|
||||
80
src/hooks/useKeyboardShortcuts.ts
Normal file
80
src/hooks/useKeyboardShortcuts.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { useEffect } from 'react';
|
||||
import { uiState } from '../stores/ui';
|
||||
import { $shader, setShaderCode } from '../stores/shader';
|
||||
import { $appSettings, cycleValueMode, randomizeVisualSettings } from '../stores/appSettings';
|
||||
import { FakeShader } from '../FakeShader';
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
const isInputFocused = activeElement?.matches('input, textarea, select');
|
||||
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
} else if (!isInputFocused) {
|
||||
if (e.key === 'h' || e.key === 'H') {
|
||||
const ui = uiState.get();
|
||||
uiState.set({ ...ui, uiVisible: !ui.uiVisible });
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
const randomCode = FakeShader.generateRandomCode();
|
||||
setShaderCode(randomCode);
|
||||
} else if (e.key === 's' || e.key === 'S') {
|
||||
shareURL();
|
||||
} else if (e.key === 'm' || e.key === 'M') {
|
||||
cycleValueMode();
|
||||
} else if (e.key === '?') {
|
||||
const ui = uiState.get();
|
||||
uiState.set({ ...ui, helpPopupOpen: true });
|
||||
} else if (e.key === 'g' || e.key === 'G') {
|
||||
randomizeVisualSettings();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const ui = uiState.get();
|
||||
uiState.set({
|
||||
...ui,
|
||||
helpPopupOpen: false,
|
||||
uiVisible: true,
|
||||
mobileMenuOpen: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
}
|
||||
|
||||
function shareURL() {
|
||||
const shader = $shader.get();
|
||||
const settings = $appSettings.get();
|
||||
|
||||
const shareData = {
|
||||
code: shader.code,
|
||||
resolution: settings.resolution,
|
||||
fps: settings.fps,
|
||||
renderMode: settings.renderMode,
|
||||
valueMode: settings.valueMode,
|
||||
uiOpacity: settings.uiOpacity,
|
||||
hueShift: settings.hueShift,
|
||||
};
|
||||
|
||||
const encoded = btoa(JSON.stringify(shareData));
|
||||
window.location.hash = encoded;
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(window.location.href)
|
||||
.then(() => {
|
||||
console.log('URL copied to clipboard');
|
||||
})
|
||||
.catch(() => {
|
||||
console.log('Copy failed');
|
||||
});
|
||||
}
|
||||
59
src/hooks/useLucideIcon.tsx
Normal file
59
src/hooks/useLucideIcon.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
|
||||
export function useLucideIcon(
|
||||
iconName: string,
|
||||
size: number = 16
|
||||
): React.RefObject<HTMLElement | null> {
|
||||
const iconRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (iconRef.current) {
|
||||
const iconMap: Record<string, string> = {
|
||||
menu: 'menu',
|
||||
close: 'x',
|
||||
help: 'help-circle',
|
||||
fullscreen: 'maximize-2',
|
||||
show: 'eye',
|
||||
hide: 'eye-off',
|
||||
random: 'dice-3',
|
||||
share: 'share-2',
|
||||
export: 'download',
|
||||
play: 'play',
|
||||
settings: 'settings',
|
||||
resolution: 'monitor',
|
||||
fps: 'zap',
|
||||
palette: 'palette',
|
||||
library: 'book-open',
|
||||
microphone: 'mic',
|
||||
'microphone-off': 'mic-off',
|
||||
};
|
||||
|
||||
const lucideIconName = iconMap[iconName] || iconName;
|
||||
iconRef.current.setAttribute('data-lucide', lucideIconName);
|
||||
iconRef.current.setAttribute('width', size.toString());
|
||||
iconRef.current.setAttribute('height', size.toString());
|
||||
iconRef.current.setAttribute('stroke-width', '2');
|
||||
|
||||
// Initialize the specific icon
|
||||
createIcons({ icons });
|
||||
}
|
||||
}, [iconName, size]);
|
||||
|
||||
return iconRef;
|
||||
}
|
||||
|
||||
// Component for rendering Lucide icons in React
|
||||
export function LucideIcon({
|
||||
name,
|
||||
size = 16,
|
||||
className = '',
|
||||
}: {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const iconRef = useLucideIcon(name, size);
|
||||
|
||||
return <i ref={iconRef} className={className} />;
|
||||
}
|
||||
80
src/hooks/useWebcam.ts
Normal file
80
src/hooks/useWebcam.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { $input } from '../stores/input';
|
||||
|
||||
export function useWebcam() {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const pixelDataRef = useRef<Uint8ClampedArray | null>(null);
|
||||
|
||||
const setupWebcam = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: 640, height: 480 }
|
||||
});
|
||||
|
||||
// Create video element
|
||||
const video = document.createElement('video');
|
||||
video.srcObject = stream;
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
videoRef.current = video;
|
||||
|
||||
// Create canvas for pixel extraction
|
||||
const canvas = document.createElement('canvas');
|
||||
canvasRef.current = canvas;
|
||||
|
||||
streamRef.current = stream;
|
||||
$input.set({ ...$input.get(), webcamEnabled: true });
|
||||
|
||||
console.log('Webcam initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to access webcam:', error);
|
||||
$input.set({ ...$input.get(), webcamEnabled: false });
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disableWebcam = useCallback(() => {
|
||||
$input.set({ ...$input.get(), webcamEnabled: false });
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
videoRef.current = null;
|
||||
canvasRef.current = null;
|
||||
pixelDataRef.current = null;
|
||||
}, []);
|
||||
|
||||
const getWebcamData = useCallback((width: number, height: number): Uint8ClampedArray | null => {
|
||||
if (!videoRef.current || !canvasRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx || video.videoWidth === 0 || video.videoHeight === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set canvas size to match shader resolution
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw video frame scaled to shader resolution
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
|
||||
// Extract pixel data
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
pixelDataRef.current = imageData.data;
|
||||
|
||||
return imageData.data;
|
||||
}, []);
|
||||
|
||||
return { setupWebcam, disableWebcam, getWebcamData };
|
||||
}
|
||||
72
src/icons.ts
72
src/icons.ts
@ -1,42 +1,46 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
|
||||
export function createIcon(name: string, size: number = 16): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'menu': 'menu',
|
||||
'close': 'x',
|
||||
'help': 'help-circle',
|
||||
'fullscreen': 'maximize-2',
|
||||
'show': 'eye',
|
||||
'hide': 'eye-off',
|
||||
'random': 'dice-3',
|
||||
'share': 'share-2',
|
||||
'export': 'download',
|
||||
'play': 'play',
|
||||
'settings': 'settings',
|
||||
'resolution': 'monitor',
|
||||
'fps': 'zap',
|
||||
'palette': 'palette',
|
||||
'library': 'book-open'
|
||||
};
|
||||
|
||||
const iconName = iconMap[name];
|
||||
if (!iconName) return '';
|
||||
|
||||
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
|
||||
const iconMap: Record<string, string> = {
|
||||
menu: 'menu',
|
||||
close: 'x',
|
||||
help: 'help-circle',
|
||||
fullscreen: 'maximize-2',
|
||||
show: 'eye',
|
||||
hide: 'eye-off',
|
||||
random: 'dice-3',
|
||||
share: 'share-2',
|
||||
export: 'download',
|
||||
play: 'play',
|
||||
settings: 'settings',
|
||||
resolution: 'monitor',
|
||||
fps: 'zap',
|
||||
palette: 'palette',
|
||||
library: 'book-open',
|
||||
};
|
||||
|
||||
const iconName = iconMap[name];
|
||||
if (!iconName) return '';
|
||||
|
||||
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
|
||||
}
|
||||
|
||||
export function addIconToButton(button: HTMLElement, iconName: string, keepText: boolean = false): void {
|
||||
const originalText = button.textContent || '';
|
||||
const iconHtml = createIcon(iconName);
|
||||
|
||||
if (keepText) {
|
||||
button.innerHTML = iconHtml + ' ' + originalText;
|
||||
} else {
|
||||
button.innerHTML = iconHtml;
|
||||
button.setAttribute('aria-label', originalText);
|
||||
}
|
||||
export function addIconToButton(
|
||||
button: HTMLElement,
|
||||
iconName: string,
|
||||
keepText: boolean = false
|
||||
): void {
|
||||
const originalText = button.textContent || '';
|
||||
const iconHtml = createIcon(iconName);
|
||||
|
||||
if (keepText) {
|
||||
button.innerHTML = iconHtml + ' ' + originalText;
|
||||
} else {
|
||||
button.innerHTML = iconHtml;
|
||||
button.setAttribute('aria-label', originalText);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeLucideIcons(): void {
|
||||
createIcons({ icons });
|
||||
}
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
865
src/main.ts
865
src/main.ts
@ -1,865 +0,0 @@
|
||||
import { FakeShader } from './FakeShader';
|
||||
import { Storage } from './Storage';
|
||||
import { addIconToButton, createIcon, initializeLucideIcons } from './icons';
|
||||
|
||||
class BitfielderApp {
|
||||
private shader: FakeShader;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private editor: HTMLTextAreaElement;
|
||||
private isAnimating: boolean = false;
|
||||
private uiVisible: boolean = true;
|
||||
private performanceWarning: HTMLElement;
|
||||
private mouseX: number = 0;
|
||||
private mouseY: number = 0;
|
||||
private mousePressed: boolean = false;
|
||||
private mouseVX: number = 0;
|
||||
private mouseVY: number = 0;
|
||||
private mouseClickTime: number = 0;
|
||||
private lastMouseX: number = 0;
|
||||
private lastMouseY: number = 0;
|
||||
private touchCount: number = 0;
|
||||
private touch0X: number = 0;
|
||||
private touch0Y: number = 0;
|
||||
private touch1X: number = 0;
|
||||
private touch1Y: number = 0;
|
||||
private pinchScale: number = 1;
|
||||
private pinchRotation: number = 0;
|
||||
private initialPinchDistance: number = 0;
|
||||
private initialPinchAngle: number = 0;
|
||||
private accelX: number = 0;
|
||||
private accelY: number = 0;
|
||||
private accelZ: number = 0;
|
||||
private gyroX: number = 0;
|
||||
private gyroY: number = 0;
|
||||
private gyroZ: number = 0;
|
||||
private audioLevel: number = 0;
|
||||
private bassLevel: number = 0;
|
||||
private midLevel: number = 0;
|
||||
private trebleLevel: number = 0;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private microphone: MediaStreamAudioSourceNode | null = null;
|
||||
private audioEnabled: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('canvas') as HTMLCanvasElement;
|
||||
this.editor = document.getElementById('editor') as HTMLTextAreaElement;
|
||||
this.performanceWarning = document.getElementById('performance-warning') as HTMLElement;
|
||||
|
||||
this.loadSettings();
|
||||
this.setupCanvas();
|
||||
this.shader = new FakeShader(this.canvas, this.editor.value);
|
||||
|
||||
this.setupEventListeners();
|
||||
this.initializeIcons();
|
||||
this.loadFromURL();
|
||||
this.renderShaderLibrary();
|
||||
this.render();
|
||||
|
||||
window.addEventListener('resize', () => this.setupCanvas());
|
||||
window.addEventListener('beforeunload', () => this.saveCurrentShader());
|
||||
}
|
||||
|
||||
private setupCanvas(): void {
|
||||
// Calculate the actual available space
|
||||
const width = window.innerWidth;
|
||||
const height = this.uiVisible ?
|
||||
window.innerHeight - 180 : // subtract topbar (40px) and editor panel (140px)
|
||||
window.innerHeight; // full height when UI is hidden
|
||||
|
||||
// Get resolution scale from dropdown
|
||||
const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement;
|
||||
const scale = parseInt(resolutionSelect.value) || 1;
|
||||
|
||||
// Set canvas internal size with resolution scaling
|
||||
this.canvas.width = Math.floor(width / scale);
|
||||
this.canvas.height = Math.floor(height / scale);
|
||||
|
||||
console.log(`Canvas setup: ${this.canvas.width}x${this.canvas.height} (scale: ${scale}x), UI visible: ${this.uiVisible}`);
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
const helpBtn = document.getElementById('help-btn')!;
|
||||
const fullscreenBtn = document.getElementById('fullscreen-btn')!;
|
||||
const hideUiBtn = document.getElementById('hide-ui-btn')!;
|
||||
const showUiBtn = document.getElementById('show-ui-btn')!;
|
||||
const randomBtn = document.getElementById('random-btn')!;
|
||||
const audioBtn = document.getElementById('audio-btn')!;
|
||||
const shareBtn = document.getElementById('share-btn')!;
|
||||
const exportPngBtn = document.getElementById('export-png-btn')!;
|
||||
const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement;
|
||||
const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement;
|
||||
const renderModeSelect = document.getElementById('render-mode-select') as HTMLSelectElement;
|
||||
const opacitySlider = document.getElementById('opacity-slider') as HTMLInputElement;
|
||||
const evalBtn = document.getElementById('eval-btn')!;
|
||||
const helpPopup = document.getElementById('help-popup')!;
|
||||
const closeBtn = helpPopup.querySelector('.close-btn')!;
|
||||
|
||||
// Mobile elements
|
||||
const hamburgerMenu = document.getElementById('hamburger-menu')!;
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay')!;
|
||||
const randomBtnMobile = document.getElementById('random-btn-mobile')!;
|
||||
const hideUiBtnMobile = document.getElementById('hide-ui-btn-mobile')!;
|
||||
const libraryBtnMobile = document.getElementById('library-btn-mobile')!;
|
||||
|
||||
// Mobile menu controls
|
||||
const resolutionSelectMobile = document.getElementById('resolution-select-mobile') as HTMLSelectElement;
|
||||
const fpsSelectMobile = document.getElementById('fps-select-mobile') as HTMLSelectElement;
|
||||
const renderModeSelectMobile = document.getElementById('render-mode-select-mobile') as HTMLSelectElement;
|
||||
const opacitySliderMobile = document.getElementById('opacity-slider-mobile') as HTMLInputElement;
|
||||
|
||||
// Mobile menu buttons
|
||||
const helpBtnMobile = document.getElementById('help-btn-mobile')!;
|
||||
const fullscreenBtnMobile = document.getElementById('fullscreen-btn-mobile')!;
|
||||
const audioBtnMobile = document.getElementById('audio-btn-mobile')!;
|
||||
const shareBtnMobile = document.getElementById('share-btn-mobile')!;
|
||||
const exportPngBtnMobile = document.getElementById('export-png-btn-mobile')!;
|
||||
|
||||
// Library elements
|
||||
const saveShaderBtn = document.getElementById('save-shader-btn')!;
|
||||
const shaderNameInput = document.getElementById('shader-name-input') as HTMLInputElement;
|
||||
const shaderSearchInput = document.getElementById('shader-search-input') as HTMLInputElement;
|
||||
|
||||
helpBtn.addEventListener('click', () => this.showHelp());
|
||||
fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
|
||||
hideUiBtn.addEventListener('click', () => this.toggleUI());
|
||||
showUiBtn.addEventListener('click', () => this.showUI());
|
||||
randomBtn.addEventListener('click', () => this.generateRandom());
|
||||
audioBtn.addEventListener('click', () => this.toggleAudio());
|
||||
shareBtn.addEventListener('click', () => this.shareURL());
|
||||
exportPngBtn.addEventListener('click', () => this.exportPNG());
|
||||
resolutionSelect.addEventListener('change', () => this.updateResolution());
|
||||
fpsSelect.addEventListener('change', () => this.updateFPS());
|
||||
renderModeSelect.addEventListener('change', () => this.updateRenderMode());
|
||||
opacitySlider.addEventListener('input', () => this.updateUIOpacity());
|
||||
evalBtn.addEventListener('click', () => this.evalShader());
|
||||
closeBtn.addEventListener('click', () => this.hideHelp());
|
||||
|
||||
// Library events
|
||||
saveShaderBtn.addEventListener('click', () => this.saveCurrentShader());
|
||||
shaderNameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.saveCurrentShader();
|
||||
}
|
||||
});
|
||||
shaderSearchInput.addEventListener('input', () => this.renderShaderLibrary());
|
||||
|
||||
// Mobile event listeners
|
||||
hamburgerMenu.addEventListener('click', () => this.toggleMobileMenu());
|
||||
mobileMenuOverlay.addEventListener('click', () => this.closeMobileMenu());
|
||||
randomBtnMobile.addEventListener('click', () => this.generateRandom());
|
||||
hideUiBtnMobile.addEventListener('click', () => this.toggleUI());
|
||||
libraryBtnMobile.addEventListener('click', () => this.toggleShaderLibrary());
|
||||
|
||||
// Mobile menu controls sync with desktop
|
||||
resolutionSelectMobile.addEventListener('change', () => {
|
||||
resolutionSelect.value = resolutionSelectMobile.value;
|
||||
this.updateResolution();
|
||||
});
|
||||
fpsSelectMobile.addEventListener('change', () => {
|
||||
fpsSelect.value = fpsSelectMobile.value;
|
||||
this.updateFPS();
|
||||
});
|
||||
renderModeSelectMobile.addEventListener('change', () => {
|
||||
renderModeSelect.value = renderModeSelectMobile.value;
|
||||
this.updateRenderMode();
|
||||
});
|
||||
opacitySliderMobile.addEventListener('input', () => {
|
||||
opacitySlider.value = opacitySliderMobile.value;
|
||||
this.updateUIOpacity();
|
||||
});
|
||||
|
||||
// Mobile menu buttons
|
||||
helpBtnMobile.addEventListener('click', () => {
|
||||
this.closeMobileMenu();
|
||||
this.showHelp();
|
||||
});
|
||||
fullscreenBtnMobile.addEventListener('click', () => {
|
||||
this.closeMobileMenu();
|
||||
this.toggleFullscreen();
|
||||
});
|
||||
audioBtnMobile.addEventListener('click', () => {
|
||||
this.closeMobileMenu();
|
||||
this.toggleAudio();
|
||||
});
|
||||
shareBtnMobile.addEventListener('click', () => {
|
||||
this.closeMobileMenu();
|
||||
this.shareURL();
|
||||
});
|
||||
exportPngBtnMobile.addEventListener('click', () => {
|
||||
this.closeMobileMenu();
|
||||
this.exportPNG();
|
||||
});
|
||||
|
||||
// Close help popup when clicking outside
|
||||
helpPopup.addEventListener('click', (e) => {
|
||||
if (e.target === helpPopup) {
|
||||
this.hideHelp();
|
||||
}
|
||||
});
|
||||
|
||||
this.editor.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.shader.setCode(this.editor.value);
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
this.toggleFullscreen();
|
||||
} else if (e.key === 'h' || e.key === 'H') {
|
||||
if (!this.editor.matches(':focus')) {
|
||||
this.toggleUI();
|
||||
}
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
if (!this.editor.matches(':focus')) {
|
||||
this.generateRandom();
|
||||
}
|
||||
} else if (e.key === 's' || e.key === 'S') {
|
||||
if (!this.editor.matches(':focus')) {
|
||||
this.shareURL();
|
||||
}
|
||||
} else if (e.key === '?') {
|
||||
if (!this.editor.matches(':focus')) {
|
||||
this.showHelp();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
this.hideHelp();
|
||||
this.showUI();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', () => this.loadFromURL());
|
||||
|
||||
// Listen for performance warnings
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data === 'performance-warning') {
|
||||
this.showPerformanceWarning();
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse tracking
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
this.lastMouseX = this.mouseX;
|
||||
this.lastMouseY = this.mouseY;
|
||||
this.mouseX = e.clientX / window.innerWidth;
|
||||
this.mouseY = 1.0 - (e.clientY / window.innerHeight); // Invert Y to match shader coordinates
|
||||
this.mouseVX = this.mouseX - this.lastMouseX;
|
||||
this.mouseVY = this.mouseY - this.lastMouseY;
|
||||
this.shader.setMousePosition(this.mouseX, this.mouseY, this.mousePressed, this.mouseVX, this.mouseVY, this.mouseClickTime);
|
||||
});
|
||||
|
||||
window.addEventListener('mousedown', () => {
|
||||
this.mousePressed = true;
|
||||
this.mouseClickTime = Date.now();
|
||||
this.shader.setMousePosition(this.mouseX, this.mouseY, this.mousePressed, this.mouseVX, this.mouseVY, this.mouseClickTime);
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
this.mousePressed = false;
|
||||
this.shader.setMousePosition(this.mouseX, this.mouseY, this.mousePressed, this.mouseVX, this.mouseVY, this.mouseClickTime);
|
||||
});
|
||||
|
||||
// Touch tracking
|
||||
window.addEventListener('touchstart', (e) => {
|
||||
// Only prevent default on canvas area for shader interaction
|
||||
if (e.target === this.canvas) {
|
||||
e.preventDefault();
|
||||
}
|
||||
this.touchCount = e.touches.length;
|
||||
this.updateTouchPositions(e.touches);
|
||||
this.initializePinchGesture(e.touches);
|
||||
});
|
||||
|
||||
window.addEventListener('touchmove', (e) => {
|
||||
// Only prevent default on canvas area to allow UI scrolling
|
||||
if (e.target === this.canvas) {
|
||||
e.preventDefault();
|
||||
}
|
||||
this.touchCount = e.touches.length;
|
||||
this.updateTouchPositions(e.touches);
|
||||
this.updatePinchGesture(e.touches);
|
||||
});
|
||||
|
||||
window.addEventListener('touchend', (e) => {
|
||||
// Only prevent default on canvas area
|
||||
if (e.target === this.canvas) {
|
||||
e.preventDefault();
|
||||
}
|
||||
this.touchCount = e.touches.length;
|
||||
if (this.touchCount === 0) {
|
||||
this.touch0X = this.touch0Y = this.touch1X = this.touch1Y = 0;
|
||||
this.pinchScale = 1;
|
||||
this.pinchRotation = 0;
|
||||
} else {
|
||||
this.updateTouchPositions(e.touches);
|
||||
}
|
||||
});
|
||||
|
||||
// Device motion tracking
|
||||
if (window.DeviceMotionEvent) {
|
||||
window.addEventListener('devicemotion', (e) => {
|
||||
if (e.acceleration) {
|
||||
this.accelX = e.acceleration.x || 0;
|
||||
this.accelY = e.acceleration.y || 0;
|
||||
this.accelZ = e.acceleration.z || 0;
|
||||
}
|
||||
if (e.rotationRate) {
|
||||
this.gyroX = e.rotationRate.alpha || 0;
|
||||
this.gyroY = e.rotationRate.beta || 0;
|
||||
this.gyroZ = e.rotationRate.gamma || 0;
|
||||
}
|
||||
this.shader.setDeviceMotion(this.accelX, this.accelY, this.accelZ, this.gyroX, this.gyroY, this.gyroZ);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const hasTime = this.editor.value.includes('t');
|
||||
|
||||
if (hasTime && !this.isAnimating) {
|
||||
this.isAnimating = true;
|
||||
this.shader.startAnimation();
|
||||
} else if (!hasTime && this.isAnimating) {
|
||||
this.isAnimating = false;
|
||||
this.shader.stopAnimation();
|
||||
this.shader.render(false);
|
||||
} else if (!hasTime) {
|
||||
this.shader.render(false);
|
||||
}
|
||||
}
|
||||
|
||||
private toggleFullscreen(): void {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
private toggleUI(): void {
|
||||
this.uiVisible = !this.uiVisible;
|
||||
const topbar = document.getElementById('topbar')!;
|
||||
const editorPanel = document.getElementById('editor-panel')!;
|
||||
const editor = document.getElementById('editor')!;
|
||||
const evalBtn = document.getElementById('eval-btn')!;
|
||||
const showUiBtn = document.getElementById('show-ui-btn')!;
|
||||
|
||||
if (this.uiVisible) {
|
||||
// Show full UI
|
||||
topbar.classList.remove('hidden');
|
||||
editorPanel.classList.remove('minimal');
|
||||
editor.classList.remove('minimal');
|
||||
evalBtn.classList.remove('minimal');
|
||||
showUiBtn.style.display = 'none';
|
||||
} else {
|
||||
// Hide topbar, make editor minimal
|
||||
topbar.classList.add('hidden');
|
||||
editorPanel.classList.add('minimal');
|
||||
editor.classList.add('minimal');
|
||||
evalBtn.classList.add('minimal');
|
||||
showUiBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
// Recalculate canvas size when UI is hidden/shown
|
||||
this.setupCanvas();
|
||||
}
|
||||
|
||||
private showUI(): void {
|
||||
this.uiVisible = true;
|
||||
const topbar = document.getElementById('topbar')!;
|
||||
const editorPanel = document.getElementById('editor-panel')!;
|
||||
const editor = document.getElementById('editor')!;
|
||||
const evalBtn = document.getElementById('eval-btn')!;
|
||||
const showUiBtn = document.getElementById('show-ui-btn')!;
|
||||
|
||||
topbar.classList.remove('hidden');
|
||||
editorPanel.classList.remove('minimal');
|
||||
editor.classList.remove('minimal');
|
||||
evalBtn.classList.remove('minimal');
|
||||
showUiBtn.style.display = 'none';
|
||||
|
||||
// Recalculate canvas size when UI is shown
|
||||
this.setupCanvas();
|
||||
}
|
||||
|
||||
private showHelp(): void {
|
||||
const helpPopup = document.getElementById('help-popup')!;
|
||||
helpPopup.style.display = 'block';
|
||||
}
|
||||
|
||||
private hideHelp(): void {
|
||||
const helpPopup = document.getElementById('help-popup')!;
|
||||
helpPopup.style.display = 'none';
|
||||
}
|
||||
|
||||
private showPerformanceWarning(): void {
|
||||
this.performanceWarning.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
this.performanceWarning.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
private shareURL(): void {
|
||||
const encoded = btoa(this.editor.value);
|
||||
window.location.hash = encoded;
|
||||
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
console.log('URL copied to clipboard');
|
||||
}).catch(() => {
|
||||
console.log('Copy failed');
|
||||
});
|
||||
}
|
||||
|
||||
private loadFromURL(): void {
|
||||
if (window.location.hash) {
|
||||
try {
|
||||
const decoded = atob(window.location.hash.substring(1));
|
||||
this.editor.value = decoded;
|
||||
this.shader.setCode(decoded);
|
||||
this.render();
|
||||
} catch (e) {
|
||||
console.error('Failed to decode URL hash:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateResolution(): void {
|
||||
this.setupCanvas();
|
||||
Storage.saveSettings({ resolution: parseInt((document.getElementById('resolution-select') as HTMLSelectElement).value) });
|
||||
}
|
||||
|
||||
private updateFPS(): void {
|
||||
const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement;
|
||||
const fps = parseInt(fpsSelect.value);
|
||||
this.shader.setTargetFPS(fps);
|
||||
Storage.saveSettings({ fps });
|
||||
}
|
||||
|
||||
private updateRenderMode(): void {
|
||||
const renderModeSelect = document.getElementById('render-mode-select') as HTMLSelectElement;
|
||||
const renderMode = renderModeSelect.value;
|
||||
this.shader.setRenderMode(renderMode);
|
||||
this.render();
|
||||
Storage.saveSettings({ renderMode });
|
||||
}
|
||||
|
||||
private updateUIOpacity(): void {
|
||||
const opacitySlider = document.getElementById('opacity-slider') as HTMLInputElement;
|
||||
const opacityValue = document.getElementById('opacity-value')!;
|
||||
const opacityValueMobile = document.getElementById('opacity-value-mobile')!;
|
||||
const opacity = parseInt(opacitySlider.value) / 100;
|
||||
|
||||
document.documentElement.style.setProperty('--ui-opacity', opacity.toString());
|
||||
opacityValue.textContent = `${opacitySlider.value}%`;
|
||||
opacityValueMobile.textContent = `${opacitySlider.value}%`;
|
||||
|
||||
Storage.saveSettings({ uiOpacity: opacity });
|
||||
}
|
||||
|
||||
private initializeIcons(): void {
|
||||
// Desktop buttons
|
||||
addIconToButton(document.getElementById('help-btn')!, 'help');
|
||||
addIconToButton(document.getElementById('fullscreen-btn')!, 'fullscreen');
|
||||
addIconToButton(document.getElementById('hide-ui-btn')!, 'hide');
|
||||
addIconToButton(document.getElementById('random-btn')!, 'random');
|
||||
addIconToButton(document.getElementById('share-btn')!, 'share');
|
||||
addIconToButton(document.getElementById('export-png-btn')!, 'export');
|
||||
|
||||
// Mobile buttons
|
||||
addIconToButton(document.getElementById('hamburger-menu')!, 'menu');
|
||||
addIconToButton(document.getElementById('library-btn-mobile')!, 'library');
|
||||
addIconToButton(document.getElementById('random-btn-mobile')!, 'random');
|
||||
addIconToButton(document.getElementById('hide-ui-btn-mobile')!, 'hide');
|
||||
addIconToButton(document.getElementById('show-ui-btn')!, 'show');
|
||||
|
||||
// Mobile menu buttons with text
|
||||
const helpIcon = document.querySelector('#help-btn-mobile .icon') as HTMLElement;
|
||||
const fullscreenIcon = document.querySelector('#fullscreen-btn-mobile .icon') as HTMLElement;
|
||||
const shareIcon = document.querySelector('#share-btn-mobile .icon') as HTMLElement;
|
||||
const exportIcon = document.querySelector('#export-png-btn-mobile .icon') as HTMLElement;
|
||||
|
||||
if (helpIcon) helpIcon.innerHTML = createIcon('help');
|
||||
if (fullscreenIcon) fullscreenIcon.innerHTML = createIcon('fullscreen');
|
||||
if (shareIcon) shareIcon.innerHTML = createIcon('share');
|
||||
if (exportIcon) exportIcon.innerHTML = createIcon('export');
|
||||
|
||||
// Initialize all Lucide icons
|
||||
initializeLucideIcons();
|
||||
}
|
||||
|
||||
private toggleMobileMenu(): void {
|
||||
const mobileMenu = document.getElementById('mobile-menu')!;
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay')!;
|
||||
const hamburgerMenu = document.getElementById('hamburger-menu')!;
|
||||
|
||||
mobileMenu.classList.toggle('open');
|
||||
mobileMenuOverlay.classList.toggle('open');
|
||||
|
||||
// Update hamburger icon
|
||||
if (mobileMenu.classList.contains('open')) {
|
||||
addIconToButton(hamburgerMenu, 'close');
|
||||
} else {
|
||||
addIconToButton(hamburgerMenu, 'menu');
|
||||
}
|
||||
|
||||
// Reinitialize icons
|
||||
initializeLucideIcons();
|
||||
}
|
||||
|
||||
private closeMobileMenu(): void {
|
||||
const mobileMenu = document.getElementById('mobile-menu')!;
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay')!;
|
||||
const hamburgerMenu = document.getElementById('hamburger-menu')!;
|
||||
|
||||
mobileMenu.classList.remove('open');
|
||||
mobileMenuOverlay.classList.remove('open');
|
||||
addIconToButton(hamburgerMenu, 'menu');
|
||||
|
||||
// Reinitialize icons
|
||||
initializeLucideIcons();
|
||||
}
|
||||
|
||||
private toggleShaderLibrary(): void {
|
||||
const shaderLibrary = document.getElementById('shader-library')!;
|
||||
shaderLibrary.classList.toggle('open');
|
||||
}
|
||||
|
||||
private evalShader(): void {
|
||||
this.shader.setCode(this.editor.value);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private exportPNG(): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = `bitfielder-${Date.now()}.png`;
|
||||
link.href = this.canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private loadSettings(): void {
|
||||
const settings = Storage.getSettings();
|
||||
|
||||
// Apply settings to UI
|
||||
(document.getElementById('resolution-select') as HTMLSelectElement).value = settings.resolution.toString();
|
||||
(document.getElementById('fps-select') as HTMLSelectElement).value = settings.fps.toString();
|
||||
(document.getElementById('render-mode-select') as HTMLSelectElement).value = settings.renderMode || 'classic';
|
||||
|
||||
// Sync mobile controls
|
||||
(document.getElementById('resolution-select-mobile') as HTMLSelectElement).value = settings.resolution.toString();
|
||||
(document.getElementById('fps-select-mobile') as HTMLSelectElement).value = settings.fps.toString();
|
||||
(document.getElementById('render-mode-select-mobile') as HTMLSelectElement).value = settings.renderMode || 'classic';
|
||||
|
||||
// Apply UI opacity
|
||||
const opacity = settings.uiOpacity ?? 0.3;
|
||||
document.documentElement.style.setProperty('--ui-opacity', opacity.toString());
|
||||
(document.getElementById('opacity-slider') as HTMLInputElement).value = (opacity * 100).toString();
|
||||
(document.getElementById('opacity-slider-mobile') as HTMLInputElement).value = (opacity * 100).toString();
|
||||
document.getElementById('opacity-value')!.textContent = `${Math.round(opacity * 100)}%`;
|
||||
document.getElementById('opacity-value-mobile')!.textContent = `${Math.round(opacity * 100)}%`;
|
||||
|
||||
// Load last shader code if no URL hash
|
||||
if (!window.location.hash) {
|
||||
this.editor.value = settings.lastShaderCode;
|
||||
}
|
||||
}
|
||||
|
||||
private saveCurrentShader(): void {
|
||||
const nameInput = document.getElementById('shader-name-input') as HTMLInputElement;
|
||||
const name = nameInput.value.trim();
|
||||
const code = this.editor.value.trim();
|
||||
|
||||
if (!code) return;
|
||||
|
||||
Storage.saveShader(name, code);
|
||||
nameInput.value = '';
|
||||
this.renderShaderLibrary();
|
||||
|
||||
// Save as last used shader
|
||||
Storage.saveSettings({ lastShaderCode: code });
|
||||
}
|
||||
|
||||
private renderShaderLibrary(): void {
|
||||
const shaderList = document.getElementById('shader-list')!;
|
||||
const searchInput = document.getElementById('shader-search-input') as HTMLInputElement;
|
||||
const searchTerm = searchInput.value.toLowerCase().trim();
|
||||
|
||||
let shaders = Storage.getShaders();
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm) {
|
||||
shaders = shaders.filter(shader =>
|
||||
shader.name.toLowerCase().includes(searchTerm) ||
|
||||
shader.code.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
if (shaders.length === 0) {
|
||||
const message = searchTerm ? 'No shaders match your search' : 'No saved shaders';
|
||||
shaderList.innerHTML = `<div style="padding: 20px; text-align: center; color: #666; font-size: 12px;">${message}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
shaderList.innerHTML = shaders.map(shader => `
|
||||
<div class="shader-item">
|
||||
<div class="shader-item-header" onclick="app.loadShader('${shader.id}')">
|
||||
<span class="shader-name" id="name-${shader.id}">${this.escapeHtml(shader.name)}</span>
|
||||
<div class="shader-actions">
|
||||
<button class="shader-action rename" onclick="event.stopPropagation(); app.startRename('${shader.id}')" title="Rename">edit</button>
|
||||
<button class="shader-action delete" onclick="event.stopPropagation(); app.deleteShader('${shader.id}')" title="Delete">del</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shader-code">${this.escapeHtml(shader.code)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Public methods for global access
|
||||
loadShader(id: string): void {
|
||||
const shaders = Storage.getShaders();
|
||||
const shader = shaders.find(s => s.id === id);
|
||||
if (shader) {
|
||||
this.editor.value = shader.code;
|
||||
this.shader.setCode(shader.code);
|
||||
this.render();
|
||||
Storage.updateShaderUsage(id);
|
||||
this.renderShaderLibrary(); // Refresh to update order
|
||||
}
|
||||
}
|
||||
|
||||
deleteShader(id: string): void {
|
||||
Storage.deleteShader(id);
|
||||
this.renderShaderLibrary();
|
||||
}
|
||||
|
||||
startRename(id: string): void {
|
||||
const nameElement = document.getElementById(`name-${id}`);
|
||||
if (!nameElement) return;
|
||||
|
||||
const shaders = Storage.getShaders();
|
||||
const shader = shaders.find(s => s.id === id);
|
||||
if (!shader) return;
|
||||
|
||||
// Replace span with input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = shader.name;
|
||||
input.style.cssText = `
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid #666;
|
||||
color: #fff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
nameElement.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishRename = () => {
|
||||
const newName = input.value.trim();
|
||||
if (newName && newName !== shader.name) {
|
||||
Storage.renameShader(id, newName);
|
||||
}
|
||||
this.renderShaderLibrary();
|
||||
};
|
||||
|
||||
input.addEventListener('blur', finishRename);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
finishRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.renderShaderLibrary();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renameShader(id: string): void {
|
||||
// This method is kept for backward compatibility but now uses startRename
|
||||
this.startRename(id);
|
||||
}
|
||||
|
||||
private generateRandom(): void {
|
||||
const randomCode = FakeShader.generateRandomCode();
|
||||
this.editor.value = randomCode;
|
||||
this.shader.setCode(randomCode);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async toggleAudio(): Promise<void> {
|
||||
const audioBtn = document.getElementById('audio-btn')!;
|
||||
const audioBtnMobile = document.getElementById('audio-btn-mobile')!;
|
||||
|
||||
if (!this.audioEnabled) {
|
||||
try {
|
||||
await this.setupAudio();
|
||||
audioBtn.textContent = 'Disable Audio';
|
||||
audioBtnMobile.innerHTML = '<span class="icon"></span> Disable Audio';
|
||||
} catch (error) {
|
||||
console.error('Failed to enable audio:', error);
|
||||
audioBtn.textContent = 'Audio Failed';
|
||||
audioBtnMobile.innerHTML = '<span class="icon"></span> Audio Failed';
|
||||
}
|
||||
} else {
|
||||
this.disableAudio();
|
||||
audioBtn.textContent = 'Enable Audio';
|
||||
audioBtnMobile.innerHTML = '<span class="icon"></span> Enable Audio';
|
||||
}
|
||||
}
|
||||
|
||||
private disableAudio(): void {
|
||||
this.audioEnabled = false;
|
||||
if (this.microphone) {
|
||||
this.microphone.disconnect();
|
||||
}
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
}
|
||||
this.audioLevel = 0;
|
||||
this.bassLevel = 0;
|
||||
this.midLevel = 0;
|
||||
this.trebleLevel = 0;
|
||||
this.shader.setAudioData(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private updateTouchPositions(touches: TouchList): void {
|
||||
if (touches.length > 0) {
|
||||
this.touch0X = touches[0].clientX / window.innerWidth;
|
||||
this.touch0Y = 1.0 - (touches[0].clientY / window.innerHeight);
|
||||
}
|
||||
if (touches.length > 1) {
|
||||
this.touch1X = touches[1].clientX / window.innerWidth;
|
||||
this.touch1Y = 1.0 - (touches[1].clientY / window.innerHeight);
|
||||
}
|
||||
this.shader.setTouchPosition(this.touchCount, this.touch0X, this.touch0Y, this.touch1X, this.touch1Y, this.pinchScale, this.pinchRotation);
|
||||
}
|
||||
|
||||
private initializePinchGesture(touches: TouchList): void {
|
||||
if (touches.length === 2) {
|
||||
const dx = touches[1].clientX - touches[0].clientX;
|
||||
const dy = touches[1].clientY - touches[0].clientY;
|
||||
this.initialPinchDistance = Math.sqrt(dx * dx + dy * dy);
|
||||
this.initialPinchAngle = Math.atan2(dy, dx);
|
||||
this.pinchScale = 1;
|
||||
this.pinchRotation = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private updatePinchGesture(touches: TouchList): void {
|
||||
if (touches.length === 2 && this.initialPinchDistance > 0) {
|
||||
const dx = touches[1].clientX - touches[0].clientX;
|
||||
const dy = touches[1].clientY - touches[0].clientY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
this.pinchScale = distance / this.initialPinchDistance;
|
||||
this.pinchRotation = angle - this.initialPinchAngle;
|
||||
this.shader.setTouchPosition(this.touchCount, this.touch0X, this.touch0Y, this.touch1X, this.touch1Y, this.pinchScale, this.pinchRotation);
|
||||
}
|
||||
}
|
||||
|
||||
private async setupAudio(): Promise<void> {
|
||||
try {
|
||||
// Check if Web Audio API is supported
|
||||
if (!window.AudioContext && !(window as any).webkitAudioContext) {
|
||||
console.warn('Web Audio API not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// Request microphone access
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// Create audio context
|
||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
|
||||
// Create analyser node
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = 256; // 128 frequency bins
|
||||
this.analyser.smoothingTimeConstant = 0.8;
|
||||
|
||||
// Create microphone source
|
||||
this.microphone = this.audioContext.createMediaStreamSource(stream);
|
||||
this.microphone.connect(this.analyser);
|
||||
|
||||
this.audioEnabled = true;
|
||||
console.log('Audio analysis enabled');
|
||||
|
||||
// Start audio analysis loop
|
||||
this.updateAudioData();
|
||||
} catch (error) {
|
||||
console.warn('Failed to setup audio:', error);
|
||||
this.audioEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private updateAudioData(): void {
|
||||
if (!this.analyser || !this.audioEnabled) return;
|
||||
|
||||
const bufferLength = this.analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
this.analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// Calculate overall audio level (RMS)
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
sum += dataArray[i] * dataArray[i];
|
||||
}
|
||||
this.audioLevel = Math.sqrt(sum / bufferLength) / 255;
|
||||
|
||||
// Calculate frequency bands
|
||||
// Low: 0-85Hz (bins 0-10 at 44.1kHz sample rate)
|
||||
// Mid: 85-5500Hz (bins 11-85)
|
||||
// High: 5500Hz+ (bins 86-127)
|
||||
const lowEnd = Math.floor(bufferLength * 0.08); // ~10 bins
|
||||
const midEnd = Math.floor(bufferLength * 0.67); // ~85 bins
|
||||
|
||||
// Bass (low frequencies)
|
||||
let bassSum = 0;
|
||||
for (let i = 0; i < lowEnd; i++) {
|
||||
bassSum += dataArray[i];
|
||||
}
|
||||
this.bassLevel = (bassSum / lowEnd) / 255;
|
||||
|
||||
// Mid frequencies
|
||||
let midSum = 0;
|
||||
for (let i = lowEnd; i < midEnd; i++) {
|
||||
midSum += dataArray[i];
|
||||
}
|
||||
this.midLevel = (midSum / (midEnd - lowEnd)) / 255;
|
||||
|
||||
// Treble (high frequencies)
|
||||
let trebleSum = 0;
|
||||
for (let i = midEnd; i < bufferLength; i++) {
|
||||
trebleSum += dataArray[i];
|
||||
}
|
||||
this.trebleLevel = (trebleSum / (bufferLength - midEnd)) / 255;
|
||||
|
||||
// Update shader with audio data
|
||||
this.shader.setAudioData(this.audioLevel, this.bassLevel, this.midLevel, this.trebleLevel);
|
||||
|
||||
// Continue the analysis loop
|
||||
requestAnimationFrame(() => this.updateAudioData());
|
||||
}
|
||||
}
|
||||
|
||||
const app = new BitfielderApp();
|
||||
(window as any).app = app;
|
||||
82
src/main.tsx
Normal file
82
src/main.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './components/App';
|
||||
import { Storage } from './Storage';
|
||||
import { $appSettings } from './stores/appSettings';
|
||||
import { setShaderCode } from './stores/shader';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
|
||||
// Load initial state from storage
|
||||
const savedSettings = Storage.getSettings();
|
||||
$appSettings.set(savedSettings);
|
||||
|
||||
// Load URL hash if present
|
||||
function loadFromURL() {
|
||||
if (window.location.hash) {
|
||||
try {
|
||||
const hash = window.location.hash.substring(1);
|
||||
console.log('Loading from URL hash:', hash);
|
||||
|
||||
const decoded = atob(hash);
|
||||
console.log('Decoded data:', decoded);
|
||||
|
||||
try {
|
||||
const shareData = JSON.parse(decoded);
|
||||
console.log('Parsed share data:', shareData);
|
||||
|
||||
if (shareData.code) {
|
||||
setShaderCode(shareData.code);
|
||||
}
|
||||
|
||||
$appSettings.set({
|
||||
resolution: shareData.resolution || savedSettings.resolution,
|
||||
fps: shareData.fps || savedSettings.fps,
|
||||
renderMode: shareData.renderMode || savedSettings.renderMode,
|
||||
valueMode: shareData.valueMode || savedSettings.valueMode,
|
||||
uiOpacity:
|
||||
shareData.uiOpacity !== undefined
|
||||
? shareData.uiOpacity
|
||||
: savedSettings.uiOpacity,
|
||||
hueShift:
|
||||
shareData.hueShift !== undefined
|
||||
? shareData.hueShift
|
||||
: savedSettings.hueShift,
|
||||
});
|
||||
|
||||
console.log('Settings updated from URL');
|
||||
} catch (jsonError) {
|
||||
console.log('JSON parse failed, falling back to old format');
|
||||
// Fall back to old format (just code as string)
|
||||
setShaderCode(decoded);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to decode URL hash:', e);
|
||||
}
|
||||
} else {
|
||||
// Load last shader code if no URL hash
|
||||
setShaderCode(savedSettings.lastShaderCode ?? 'x^y');
|
||||
}
|
||||
}
|
||||
|
||||
// Main App component that includes keyboard shortcuts
|
||||
function AppWithShortcuts() {
|
||||
useKeyboardShortcuts();
|
||||
return <App />;
|
||||
}
|
||||
|
||||
// Set up hash change listener
|
||||
window.addEventListener('hashchange', loadFromURL);
|
||||
|
||||
// Initialize the app
|
||||
loadFromURL();
|
||||
|
||||
// Mount React app
|
||||
const container = document.getElementById('app');
|
||||
if (!container) {
|
||||
// Create app container if it doesn't exist
|
||||
const appDiv = document.createElement('div');
|
||||
appDiv.id = 'app';
|
||||
document.body.appendChild(appDiv);
|
||||
}
|
||||
|
||||
const root = createRoot(container || document.getElementById('app')!);
|
||||
root.render(<AppWithShortcuts />);
|
||||
188
src/shader/core/InputManager.ts
Normal file
188
src/shader/core/InputManager.ts
Normal file
@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Manages all input tracking (mouse, touch, accelerometer, audio)
|
||||
* Extracted from FakeShader to follow Single Responsibility Principle
|
||||
*/
|
||||
export class InputManager {
|
||||
// Mouse state
|
||||
private mouseX: number = 0;
|
||||
private mouseY: number = 0;
|
||||
private mousePressed: boolean = false;
|
||||
private mouseVX: number = 0;
|
||||
private mouseVY: number = 0;
|
||||
private mouseClickTime: number = 0;
|
||||
|
||||
// Touch state
|
||||
private touchCount: number = 0;
|
||||
private touch0X: number = 0;
|
||||
private touch0Y: number = 0;
|
||||
private touch1X: number = 0;
|
||||
private touch1Y: number = 0;
|
||||
private pinchScale: number = 1;
|
||||
private pinchRotation: number = 0;
|
||||
|
||||
// Accelerometer state
|
||||
private accelX: number = 0;
|
||||
private accelY: number = 0;
|
||||
private accelZ: number = 0;
|
||||
|
||||
// Gyroscope state
|
||||
private gyroX: number = 0;
|
||||
private gyroY: number = 0;
|
||||
private gyroZ: number = 0;
|
||||
|
||||
// Audio state
|
||||
private audioLevel: number = 0;
|
||||
private bassLevel: number = 0;
|
||||
private midLevel: number = 0;
|
||||
private trebleLevel: number = 0;
|
||||
private currentBPM: number = 120;
|
||||
|
||||
setMousePosition(x: number, y: number): void {
|
||||
this.mouseX = x;
|
||||
this.mouseY = y;
|
||||
}
|
||||
|
||||
setMousePressed(pressed: boolean): void {
|
||||
this.mousePressed = pressed;
|
||||
if (pressed) {
|
||||
this.mouseClickTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
setMouseVelocity(vx: number, vy: number): void {
|
||||
this.mouseVX = vx;
|
||||
this.mouseVY = vy;
|
||||
}
|
||||
|
||||
setTouchData(
|
||||
count: number,
|
||||
x0: number = 0,
|
||||
y0: number = 0,
|
||||
x1: number = 0,
|
||||
y1: number = 0,
|
||||
scale: number = 1,
|
||||
rotation: number = 0
|
||||
): void {
|
||||
this.touchCount = count;
|
||||
this.touch0X = x0;
|
||||
this.touch0Y = y0;
|
||||
this.touch1X = x1;
|
||||
this.touch1Y = y1;
|
||||
this.pinchScale = scale;
|
||||
this.pinchRotation = rotation;
|
||||
}
|
||||
|
||||
setAccelerometer(x: number, y: number, z: number): void {
|
||||
this.accelX = x;
|
||||
this.accelY = y;
|
||||
this.accelZ = z;
|
||||
}
|
||||
|
||||
setGyroscope(x: number, y: number, z: number): void {
|
||||
this.gyroX = x;
|
||||
this.gyroY = y;
|
||||
this.gyroZ = z;
|
||||
}
|
||||
|
||||
setAudioLevels(
|
||||
level: number,
|
||||
bass: number,
|
||||
mid: number,
|
||||
treble: number,
|
||||
bpm: number
|
||||
): void {
|
||||
this.audioLevel = level;
|
||||
this.bassLevel = bass;
|
||||
this.midLevel = mid;
|
||||
this.trebleLevel = treble;
|
||||
this.currentBPM = bpm;
|
||||
}
|
||||
|
||||
// Getters for all input values
|
||||
getMouseData() {
|
||||
return {
|
||||
x: this.mouseX,
|
||||
y: this.mouseY,
|
||||
pressed: this.mousePressed,
|
||||
vx: this.mouseVX,
|
||||
vy: this.mouseVY,
|
||||
clickTime: this.mouseClickTime,
|
||||
};
|
||||
}
|
||||
|
||||
getTouchData() {
|
||||
return {
|
||||
count: this.touchCount,
|
||||
x0: this.touch0X,
|
||||
y0: this.touch0Y,
|
||||
x1: this.touch1X,
|
||||
y1: this.touch1Y,
|
||||
scale: this.pinchScale,
|
||||
rotation: this.pinchRotation,
|
||||
};
|
||||
}
|
||||
|
||||
getAccelerometerData() {
|
||||
return {
|
||||
x: this.accelX,
|
||||
y: this.accelY,
|
||||
z: this.accelZ,
|
||||
};
|
||||
}
|
||||
|
||||
getGyroscopeData() {
|
||||
return {
|
||||
x: this.gyroX,
|
||||
y: this.gyroY,
|
||||
z: this.gyroZ,
|
||||
};
|
||||
}
|
||||
|
||||
getAudioData() {
|
||||
return {
|
||||
level: this.audioLevel,
|
||||
bass: this.bassLevel,
|
||||
mid: this.midLevel,
|
||||
treble: this.trebleLevel,
|
||||
bpm: this.currentBPM,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to populate worker message with all input data
|
||||
populateWorkerMessage(message: any): void {
|
||||
const mouse = this.getMouseData();
|
||||
const touch = this.getTouchData();
|
||||
const accel = this.getAccelerometerData();
|
||||
const gyro = this.getGyroscopeData();
|
||||
const audio = this.getAudioData();
|
||||
|
||||
message.mouseX = mouse.x;
|
||||
message.mouseY = mouse.y;
|
||||
message.mousePressed = mouse.pressed;
|
||||
message.mouseVX = mouse.vx;
|
||||
message.mouseVY = mouse.vy;
|
||||
message.mouseClickTime = mouse.clickTime;
|
||||
|
||||
message.touchCount = touch.count;
|
||||
message.touch0X = touch.x0;
|
||||
message.touch0Y = touch.y0;
|
||||
message.touch1X = touch.x1;
|
||||
message.touch1Y = touch.y1;
|
||||
message.pinchScale = touch.scale;
|
||||
message.pinchRotation = touch.rotation;
|
||||
|
||||
message.accelX = accel.x;
|
||||
message.accelY = accel.y;
|
||||
message.accelZ = accel.z;
|
||||
|
||||
message.gyroX = gyro.x;
|
||||
message.gyroY = gyro.y;
|
||||
message.gyroZ = gyro.z;
|
||||
|
||||
message.audioLevel = audio.level;
|
||||
message.bassLevel = audio.bass;
|
||||
message.midLevel = audio.mid;
|
||||
message.trebleLevel = audio.treble;
|
||||
message.bpm = audio.bpm;
|
||||
}
|
||||
}
|
||||
114
src/shader/core/RenderController.ts
Normal file
114
src/shader/core/RenderController.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { TIMING } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Manages animation timing and frame rate control
|
||||
* Extracted from FakeShader for better separation of concerns
|
||||
*/
|
||||
export class RenderController {
|
||||
private animationId: number | null = null;
|
||||
private startTime: number = Date.now();
|
||||
private targetFPS: number = TIMING.DEFAULT_FPS;
|
||||
private frameInterval: number = TIMING.MILLISECONDS_PER_SECOND / this.targetFPS;
|
||||
private lastFrameTime: number = 0;
|
||||
private timeSpeed: number = 1.0;
|
||||
private isRendering: boolean = false;
|
||||
private pendingRenders: string[] = [];
|
||||
private idCounter: number = 0;
|
||||
|
||||
private onRenderFrame?: (time: number, renderId: string) => void;
|
||||
|
||||
setRenderFrameHandler(handler: (time: number, renderId: string) => void): void {
|
||||
this.onRenderFrame = handler;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.animationId !== null) return;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (timestamp - this.lastFrameTime >= this.frameInterval) {
|
||||
const currentTime = (Date.now() - this.startTime) / TIMING.MILLISECONDS_PER_SECOND * this.timeSpeed;
|
||||
const renderId = this.generateId();
|
||||
|
||||
this.onRenderFrame?.(currentTime, renderId);
|
||||
this.lastFrameTime = timestamp;
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.animationId !== null) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
setTargetFPS(fps: number): void {
|
||||
this.targetFPS = Math.max(TIMING.MIN_FPS, Math.min(TIMING.MAX_FPS, fps));
|
||||
this.frameInterval = TIMING.MILLISECONDS_PER_SECOND / this.targetFPS;
|
||||
}
|
||||
|
||||
setTimeSpeed(speed: number): void {
|
||||
this.timeSpeed = speed;
|
||||
}
|
||||
|
||||
getTimeSpeed(): number {
|
||||
return this.timeSpeed;
|
||||
}
|
||||
|
||||
getCurrentTime(): number {
|
||||
return (Date.now() - this.startTime) / TIMING.MILLISECONDS_PER_SECOND * this.timeSpeed;
|
||||
}
|
||||
|
||||
isAnimating(): boolean {
|
||||
return this.animationId !== null;
|
||||
}
|
||||
|
||||
generateId(): string {
|
||||
return `render_${this.idCounter++}_${Date.now()}`;
|
||||
}
|
||||
|
||||
setRenderingState(isRendering: boolean): void {
|
||||
this.isRendering = isRendering;
|
||||
}
|
||||
|
||||
isCurrentlyRendering(): boolean {
|
||||
return this.isRendering;
|
||||
}
|
||||
|
||||
addPendingRender(renderId: string): void {
|
||||
this.pendingRenders.push(renderId);
|
||||
|
||||
// Keep only the latest render to avoid backlog
|
||||
if (this.pendingRenders.length > 3) {
|
||||
const latestId = this.pendingRenders[this.pendingRenders.length - 1];
|
||||
this.pendingRenders = [latestId];
|
||||
}
|
||||
}
|
||||
|
||||
removePendingRender(renderId: string): void {
|
||||
const index = this.pendingRenders.indexOf(renderId);
|
||||
if (index !== -1) {
|
||||
this.pendingRenders.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
getPendingRenders(): string[] {
|
||||
return [...this.pendingRenders];
|
||||
}
|
||||
|
||||
clearPendingRenders(): void {
|
||||
this.pendingRenders = [];
|
||||
}
|
||||
|
||||
getFrameRate(): number {
|
||||
return this.targetFPS;
|
||||
}
|
||||
|
||||
getFrameInterval(): number {
|
||||
return this.frameInterval;
|
||||
}
|
||||
}
|
||||
53
src/shader/core/ShaderCache.ts
Normal file
53
src/shader/core/ShaderCache.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { LRUCache } from '../../utils/LRUCache';
|
||||
import { ShaderFunction } from '../types';
|
||||
import { PERFORMANCE } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Manages caching for compiled shaders and image data
|
||||
*/
|
||||
export class ShaderCache {
|
||||
private imageDataCache: LRUCache<string, ImageData>;
|
||||
private compilationCache: LRUCache<string, ShaderFunction>;
|
||||
|
||||
constructor() {
|
||||
this.imageDataCache = new LRUCache(PERFORMANCE.IMAGE_DATA_CACHE_SIZE);
|
||||
this.compilationCache = new LRUCache(PERFORMANCE.COMPILATION_CACHE_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cached ImageData or creates new one
|
||||
*/
|
||||
getOrCreateImageData(width: number, height: number): ImageData {
|
||||
const key = `${width}x${height}`;
|
||||
let imageData = this.imageDataCache.get(key);
|
||||
|
||||
if (!imageData) {
|
||||
imageData = new ImageData(width, height);
|
||||
this.imageDataCache.set(key, imageData);
|
||||
}
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cached compiled shader function
|
||||
*/
|
||||
getCompiledShader(codeHash: string): ShaderFunction | undefined {
|
||||
return this.compilationCache.get(codeHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches compiled shader function
|
||||
*/
|
||||
setCompiledShader(codeHash: string, compiledFunction: ShaderFunction): void {
|
||||
this.compilationCache.set(codeHash, compiledFunction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all caches
|
||||
*/
|
||||
clear(): void {
|
||||
this.imageDataCache.clear();
|
||||
this.compilationCache.clear();
|
||||
}
|
||||
}
|
||||
145
src/shader/core/ShaderCompiler.ts
Normal file
145
src/shader/core/ShaderCompiler.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { ShaderFunction } from '../types';
|
||||
import { PERFORMANCE } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Handles shader code compilation and optimization
|
||||
*/
|
||||
export class ShaderCompiler {
|
||||
/**
|
||||
* Compiles shader code into an executable function
|
||||
*/
|
||||
static compile(code: string): ShaderFunction {
|
||||
const safeCode = this.sanitizeCode(code);
|
||||
|
||||
// Check if expression is static (contains no variables)
|
||||
const isStatic = this.isStaticExpression(safeCode);
|
||||
|
||||
if (isStatic) {
|
||||
// Pre-compute static value
|
||||
const staticValue = this.evaluateStaticExpression(safeCode);
|
||||
return (_ctx) => staticValue;
|
||||
}
|
||||
|
||||
return new Function(
|
||||
'ctx',
|
||||
`
|
||||
// Destructure context for backward compatibility with existing shader code
|
||||
const {
|
||||
x, y, t, i, r, a, u, v, c, f, d, n, b, bn, bs, be, bw,
|
||||
w, h, p, z, j, o, g, m, l, k, s, e, mouseX, mouseY,
|
||||
mousePressed, mouseVX, mouseVY, mouseClickTime, touchCount,
|
||||
touch0X, touch0Y, touch1X, touch1Y, pinchScale, pinchRotation,
|
||||
accelX, accelY, accelZ, gyroX, gyroY, gyroZ, audioLevel,
|
||||
bassLevel, midLevel, trebleLevel, bpm, _t, bx, by, sx, sy, qx, qy
|
||||
} = ctx;
|
||||
|
||||
// Shader-specific helper functions
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const smooth = (edge, x) => { const t = Math.min(Math.max((x - edge) / (1 - edge), 0), 1); return t * t * (3 - 2 * t); };
|
||||
const step = (edge, x) => x < edge ? 0 : 1;
|
||||
const fract = (x) => x - Math.floor(x);
|
||||
const mix = (a, b, t) => a + (b - a) * t;
|
||||
|
||||
// Timeout protection
|
||||
const startTime = performance.now();
|
||||
let iterations = 0;
|
||||
|
||||
function checkTimeout() {
|
||||
iterations++;
|
||||
if (iterations % ${PERFORMANCE.TIMEOUT_CHECK_INTERVAL} === 0 && performance.now() - startTime > ${PERFORMANCE.MAX_SHADER_TIMEOUT_MS}) {
|
||||
throw new Error('Shader timeout');
|
||||
}
|
||||
}
|
||||
|
||||
return (function() {
|
||||
checkTimeout();
|
||||
return ${safeCode};
|
||||
})();
|
||||
`
|
||||
) as ShaderFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if shader code contains only static expressions (no variables)
|
||||
*/
|
||||
private static isStaticExpression(code: string): boolean {
|
||||
// Check if code contains any variables using regex for better accuracy
|
||||
const variablePattern = /\b(x|y|t|i|r|a|u|v|c|f|d|n|b|bn|bs|be|bw|m|l|k|s|e|w|h|p|z|j|o|g|bpm|bx|by|sx|sy|qx|qy|mouse[XY]|mousePressed|mouseV[XY]|mouseClickTime|touchCount|touch[01][XY]|pinchScale|pinchRotation|accel[XYZ]|gyro[XYZ]|audioLevel|bassLevel|midLevel|trebleLevel)\b/;
|
||||
|
||||
return !variablePattern.test(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates static expressions safely
|
||||
*/
|
||||
private static evaluateStaticExpression(code: string): number {
|
||||
try {
|
||||
// Safely evaluate numeric expression
|
||||
const result = new Function(`return ${code}`)();
|
||||
return isFinite(result) ? result : 0;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes shader code by auto-prefixing Math functions and constants
|
||||
*/
|
||||
private static sanitizeCode(code: string): string {
|
||||
// Create a single regex pattern for all replacements
|
||||
const mathFunctions = [
|
||||
'abs', 'acos', 'asin', 'atan', 'atan2', 'ceil', 'cos', 'exp',
|
||||
'floor', 'log', 'max', 'min', 'pow', 'random', 'round', 'sin',
|
||||
'sqrt', 'tan', 'trunc', 'sign', 'cbrt', 'hypot', 'imul', 'fround',
|
||||
'clz32', 'acosh', 'asinh', 'atanh', 'cosh', 'sinh', 'tanh',
|
||||
'expm1', 'log1p', 'log10', 'log2'
|
||||
];
|
||||
|
||||
const mathConstants = {
|
||||
'PI': 'Math.PI',
|
||||
'E': 'Math.E',
|
||||
'LN2': 'Math.LN2',
|
||||
'LN10': 'Math.LN10',
|
||||
'LOG2E': 'Math.LOG2E',
|
||||
'LOG10E': 'Math.LOG10E',
|
||||
'SQRT1_2': 'Math.SQRT1_2',
|
||||
'SQRT2': 'Math.SQRT2'
|
||||
};
|
||||
|
||||
// Build combined regex pattern
|
||||
const functionPattern = mathFunctions.join('|');
|
||||
const constantPattern = Object.keys(mathConstants).join('|');
|
||||
const combinedPattern = new RegExp(
|
||||
`\\b(${functionPattern})\\(|\\b(${constantPattern})\\b|\\bt\\s*\\(`,
|
||||
'g'
|
||||
);
|
||||
|
||||
// Single pass replacement
|
||||
const processedCode = code.replace(combinedPattern, (match, func, constant) => {
|
||||
if (func) {
|
||||
return `Math.${func}(`;
|
||||
} else if (constant) {
|
||||
return mathConstants[constant as keyof typeof mathConstants];
|
||||
} else if (match.startsWith('t')) {
|
||||
return '_t(';
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
return processedCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a hash for shader code caching
|
||||
*/
|
||||
static hashCode(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
}
|
||||
188
src/shader/core/WorkerPool.ts
Normal file
188
src/shader/core/WorkerPool.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { WorkerMessage, WorkerResponse } from '../types';
|
||||
import { WORKER } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Manages worker lifecycle and multi-worker rendering
|
||||
* Extracted from FakeShader for better separation of concerns
|
||||
*/
|
||||
export class WorkerPool {
|
||||
private workers: Worker[] = [];
|
||||
private workerCount: number;
|
||||
private tileResults: Map<number, ImageData> = new Map();
|
||||
private tilesCompleted: number = 0;
|
||||
private totalTiles: number = 0;
|
||||
private onRenderComplete?: (imageData: ImageData) => void;
|
||||
private onError?: (error: any) => void;
|
||||
|
||||
constructor() {
|
||||
this.workerCount = navigator.hardwareConcurrency || WORKER.FALLBACK_CORE_COUNT;
|
||||
this.workerCount = Math.min(this.workerCount, WORKER.MAX_WORKERS);
|
||||
console.log(`WorkerPool: Using ${this.workerCount} workers for rendering`);
|
||||
this.initializeWorkers();
|
||||
}
|
||||
|
||||
private initializeWorkers(): void {
|
||||
for (let i = 0; i < this.workerCount; i++) {
|
||||
const worker = new Worker(new URL('../worker/ShaderWorker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
worker.onmessage = (event) => {
|
||||
this.handleWorkerMessage(event.data, i);
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
console.error(`Worker ${i} error:`, error);
|
||||
this.onError?.(error);
|
||||
};
|
||||
|
||||
this.workers.push(worker);
|
||||
}
|
||||
}
|
||||
|
||||
private handleWorkerMessage(response: WorkerResponse, workerIndex: number): void {
|
||||
switch (response.type) {
|
||||
case 'compiled':
|
||||
// Handle compilation response if needed
|
||||
break;
|
||||
|
||||
case 'rendered':
|
||||
if (this.workerCount > 1) {
|
||||
this.handleTileResult(response, workerIndex);
|
||||
} else {
|
||||
this.onRenderComplete?.(response.imageData!);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error(`Worker ${workerIndex} error:`, response.error);
|
||||
this.onError?.(response.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTileResult(response: WorkerResponse, workerIndex: number): void {
|
||||
if (!response.imageData || response.tileIndex === undefined) return;
|
||||
|
||||
this.tileResults.set(response.tileIndex, response.imageData);
|
||||
this.tilesCompleted++;
|
||||
|
||||
if (this.tilesCompleted >= this.totalTiles) {
|
||||
this.assembleTiles();
|
||||
}
|
||||
}
|
||||
|
||||
private assembleTiles(): void {
|
||||
if (this.tileResults.size === 0) return;
|
||||
|
||||
const firstTile = this.tileResults.get(0);
|
||||
if (!firstTile) return;
|
||||
|
||||
const tileWidth = firstTile.width;
|
||||
const tileHeight = firstTile.height;
|
||||
const tilesPerRow = Math.ceil(Math.sqrt(this.totalTiles));
|
||||
const totalWidth = tileWidth;
|
||||
const totalHeight = tileHeight * this.totalTiles;
|
||||
|
||||
const canvas = new OffscreenCanvas(totalWidth, totalHeight);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const finalImageData = ctx.createImageData(totalWidth, totalHeight);
|
||||
|
||||
for (let i = 0; i < this.totalTiles; i++) {
|
||||
const tileData = this.tileResults.get(i);
|
||||
if (!tileData) continue;
|
||||
|
||||
const startY = i * tileHeight;
|
||||
const sourceData = tileData.data;
|
||||
const targetData = finalImageData.data;
|
||||
|
||||
for (let y = 0; y < tileHeight; y++) {
|
||||
for (let x = 0; x < tileWidth; x++) {
|
||||
const sourceIndex = (y * tileWidth + x) * 4;
|
||||
const targetIndex = ((startY + y) * totalWidth + x) * 4;
|
||||
|
||||
targetData[targetIndex] = sourceData[sourceIndex];
|
||||
targetData[targetIndex + 1] = sourceData[sourceIndex + 1];
|
||||
targetData[targetIndex + 2] = sourceData[sourceIndex + 2];
|
||||
targetData[targetIndex + 3] = sourceData[sourceIndex + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.onRenderComplete?.(finalImageData);
|
||||
this.tileResults.clear();
|
||||
this.tilesCompleted = 0;
|
||||
}
|
||||
|
||||
compile(code: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = this.workers[0]; // Use first worker for compilation
|
||||
|
||||
const compileMessage = {
|
||||
type: 'compile',
|
||||
code,
|
||||
};
|
||||
|
||||
const handleResponse = (event: MessageEvent) => {
|
||||
const response = event.data;
|
||||
if (response.type === 'compiled') {
|
||||
worker.removeEventListener('message', handleResponse);
|
||||
resolve();
|
||||
} else if (response.type === 'error') {
|
||||
worker.removeEventListener('message', handleResponse);
|
||||
reject(response.error);
|
||||
}
|
||||
};
|
||||
|
||||
worker.addEventListener('message', handleResponse);
|
||||
worker.postMessage(compileMessage);
|
||||
});
|
||||
}
|
||||
|
||||
renderSingleWorker(message: WorkerMessage): void {
|
||||
const worker = this.workers[0];
|
||||
worker.postMessage(message);
|
||||
}
|
||||
|
||||
renderMultiWorker(baseMessage: WorkerMessage, width: number, height: number): void {
|
||||
this.tileResults.clear();
|
||||
this.tilesCompleted = 0;
|
||||
this.totalTiles = this.workerCount;
|
||||
|
||||
const tileHeight = Math.ceil(height / this.totalTiles);
|
||||
|
||||
for (let i = 0; i < this.totalTiles; i++) {
|
||||
const worker = this.workers[i];
|
||||
const startY = i * tileHeight;
|
||||
const endY = Math.min((i + 1) * tileHeight, height);
|
||||
const actualTileHeight = endY - startY;
|
||||
|
||||
const tileMessage: WorkerMessage = {
|
||||
...baseMessage,
|
||||
startY,
|
||||
height: actualTileHeight,
|
||||
tileIndex: i,
|
||||
};
|
||||
|
||||
worker.postMessage(tileMessage);
|
||||
}
|
||||
}
|
||||
|
||||
setRenderCompleteHandler(handler: (imageData: ImageData) => void): void {
|
||||
this.onRenderComplete = handler;
|
||||
}
|
||||
|
||||
setErrorHandler(handler: (error: any) => void): void {
|
||||
this.onError = handler;
|
||||
}
|
||||
|
||||
getWorkerCount(): number {
|
||||
return this.workerCount;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.workers.forEach(worker => worker.terminate());
|
||||
this.workers = [];
|
||||
this.tileResults.clear();
|
||||
}
|
||||
}
|
||||
15
src/shader/index.ts
Normal file
15
src/shader/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Public API for the shader system
|
||||
*/
|
||||
|
||||
// Export types
|
||||
export type { ShaderContext, ShaderFunction, WorkerMessage, WorkerResponse } from './types';
|
||||
|
||||
// Export main classes
|
||||
export { FakeShader } from '../FakeShader';
|
||||
|
||||
// Export utilities
|
||||
export { ShaderCompiler } from './core/ShaderCompiler';
|
||||
export { ShaderCache } from './core/ShaderCache';
|
||||
export { FeedbackSystem } from './rendering/FeedbackSystem';
|
||||
export { PixelRenderer } from './rendering/PixelRenderer';
|
||||
200
src/shader/rendering/FeedbackSystem.ts
Normal file
200
src/shader/rendering/FeedbackSystem.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { LUMINANCE_WEIGHTS } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Manages feedback buffers for shader rendering
|
||||
*/
|
||||
export class FeedbackSystem {
|
||||
private feedbackBuffer: Float32Array | null = null;
|
||||
private previousFeedbackBuffer: Float32Array | null = null;
|
||||
private stateBuffer: Float32Array | null = null;
|
||||
private echoBuffers: Float32Array[] = [];
|
||||
private echoFrameCounter: number = 0;
|
||||
private echoInterval: number = 30; // Store echo every 30 frames (~0.5s at 60fps)
|
||||
|
||||
/**
|
||||
* Initializes feedback buffers for given dimensions
|
||||
*/
|
||||
initializeBuffers(width: number, height: number): void {
|
||||
const bufferSize = width * height;
|
||||
|
||||
if (!this.feedbackBuffer || this.feedbackBuffer.length !== bufferSize) {
|
||||
this.feedbackBuffer = new Float32Array(bufferSize);
|
||||
this.previousFeedbackBuffer = new Float32Array(bufferSize);
|
||||
this.stateBuffer = new Float32Array(bufferSize);
|
||||
|
||||
// Initialize echo buffers (4 buffers for different time delays)
|
||||
this.echoBuffers = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.echoBuffers.push(new Float32Array(bufferSize));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates feedback value for a pixel
|
||||
*/
|
||||
updateFeedback(pixelIndex: number, r: number, g: number, b: number, deltaTime: number): void {
|
||||
if (!this.feedbackBuffer) return;
|
||||
|
||||
// Use the actual displayed luminance as feedback (0-255 range)
|
||||
const luminance = (r * LUMINANCE_WEIGHTS.RED + g * LUMINANCE_WEIGHTS.GREEN + b * LUMINANCE_WEIGHTS.BLUE);
|
||||
|
||||
// Frame rate independent decay
|
||||
const decayFactor = Math.pow(0.95, deltaTime * 60); // 5% decay at 60fps
|
||||
|
||||
// Simple mixing to prevent oscillation
|
||||
const previousValue = this.feedbackBuffer[pixelIndex] || 0;
|
||||
const mixRatio = Math.min(deltaTime * 10, 0.3); // Max 30% new value per frame
|
||||
|
||||
let newFeedback = luminance * mixRatio + previousValue * (1 - mixRatio);
|
||||
newFeedback *= decayFactor;
|
||||
|
||||
// Clamp and store
|
||||
this.feedbackBuffer[pixelIndex] = Math.max(0, Math.min(255, newFeedback));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates state buffer for a pixel
|
||||
*/
|
||||
updateState(pixelIndex: number, stateValue: number): void {
|
||||
if (!this.stateBuffer) return;
|
||||
this.stateBuffer[pixelIndex] = stateValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets feedback value for a pixel
|
||||
*/
|
||||
getFeedback(pixelIndex: number): number {
|
||||
return this.feedbackBuffer ? this.feedbackBuffer[pixelIndex] || 0 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets neighbor feedback values
|
||||
*/
|
||||
getNeighborFeedback(_pixelIndex: number, x: number, y: number, width: number, height: number): {
|
||||
north: number;
|
||||
south: number;
|
||||
east: number;
|
||||
west: number;
|
||||
} {
|
||||
if (!this.feedbackBuffer) {
|
||||
return { north: 0, south: 0, east: 0, west: 0 };
|
||||
}
|
||||
|
||||
let north = 0, south = 0, east = 0, west = 0;
|
||||
|
||||
// North neighbor (bounds safe)
|
||||
if (y > 0) north = this.feedbackBuffer[(y - 1) * width + x] || 0;
|
||||
// South neighbor (bounds safe)
|
||||
if (y < height - 1) south = this.feedbackBuffer[(y + 1) * width + x] || 0;
|
||||
// East neighbor (bounds safe)
|
||||
if (x < width - 1) east = this.feedbackBuffer[y * width + (x + 1)] || 0;
|
||||
// West neighbor (bounds safe)
|
||||
if (x > 0) west = this.feedbackBuffer[y * width + (x - 1)] || 0;
|
||||
|
||||
return { north, south, east, west };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates momentum (change from previous frame)
|
||||
*/
|
||||
getMomentum(pixelIndex: number): number {
|
||||
if (!this.feedbackBuffer || !this.previousFeedbackBuffer) return 0;
|
||||
|
||||
const currentValue = this.feedbackBuffer[pixelIndex] || 0;
|
||||
const previousValue = this.previousFeedbackBuffer[pixelIndex] || 0;
|
||||
return (currentValue - previousValue) * 0.5; // Scale for stability
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates laplacian/diffusion
|
||||
*/
|
||||
getLaplacian(pixelIndex: number, x: number, y: number, width: number, height: number): number {
|
||||
if (!this.feedbackBuffer) return 0;
|
||||
|
||||
const neighbors = this.getNeighborFeedback(pixelIndex, x, y, width, height);
|
||||
const currentValue = this.feedbackBuffer[pixelIndex] || 0;
|
||||
|
||||
return (neighbors.north + neighbors.south + neighbors.east + neighbors.west - currentValue * 4) * 0.25;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates curvature/contrast
|
||||
*/
|
||||
getCurvature(pixelIndex: number, x: number, y: number, width: number, height: number): number {
|
||||
if (!this.feedbackBuffer) return 0;
|
||||
|
||||
const neighbors = this.getNeighborFeedback(pixelIndex, x, y, width, height);
|
||||
const gradientX = (neighbors.east - neighbors.west) * 0.5;
|
||||
const gradientY = (neighbors.south - neighbors.north) * 0.5;
|
||||
|
||||
return Math.sqrt(gradientX * gradientX + gradientY * gradientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets/updates state value
|
||||
*/
|
||||
getState(pixelIndex: number, feedbackValue: number, deltaTime: number): number {
|
||||
if (!this.stateBuffer) return 0;
|
||||
|
||||
let currentState = this.stateBuffer[pixelIndex] || 0;
|
||||
|
||||
// State accumulates when feedback is high, decays when low
|
||||
if (feedbackValue > 128) {
|
||||
currentState = Math.min(255, currentState + deltaTime * 200); // Accumulate
|
||||
} else {
|
||||
currentState = Math.max(0, currentState - deltaTime * 100); // Decay
|
||||
}
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets echo value
|
||||
*/
|
||||
getEcho(pixelIndex: number, time: number): number {
|
||||
if (this.echoBuffers.length === 0) return 0;
|
||||
|
||||
// Cycle through different echo delays based on time
|
||||
const echoIndex = Math.floor(time * 2) % this.echoBuffers.length; // Change every 0.5 seconds
|
||||
const echoBuffer = this.echoBuffers[echoIndex];
|
||||
return echoBuffer ? echoBuffer[pixelIndex] || 0 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates echo buffers at regular intervals
|
||||
*/
|
||||
updateEchoBuffers(): void {
|
||||
if (!this.feedbackBuffer || this.echoBuffers.length === 0) return;
|
||||
|
||||
this.echoFrameCounter++;
|
||||
if (this.echoFrameCounter >= this.echoInterval) {
|
||||
this.echoFrameCounter = 0;
|
||||
|
||||
// Rotate echo buffers: shift all buffers forward and store current in first buffer
|
||||
for (let i = this.echoBuffers.length - 1; i > 0; i--) {
|
||||
if (this.echoBuffers[i] && this.echoBuffers[i - 1]) {
|
||||
this.echoBuffers[i].set(this.echoBuffers[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Store current feedback in first echo buffer
|
||||
if (this.echoBuffers[0]) {
|
||||
this.echoBuffers[0].set(this.feedbackBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes frame processing
|
||||
*/
|
||||
finalizeFrame(): void {
|
||||
// Copy current feedback to previous for next frame momentum calculations
|
||||
if (this.feedbackBuffer && this.previousFeedbackBuffer) {
|
||||
this.previousFeedbackBuffer.set(this.feedbackBuffer);
|
||||
}
|
||||
|
||||
// Update echo buffers
|
||||
this.updateEchoBuffers();
|
||||
}
|
||||
}
|
||||
242
src/shader/rendering/PixelRenderer.ts
Normal file
242
src/shader/rendering/PixelRenderer.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { ShaderFunction, ShaderContext, WorkerMessage } from '../types';
|
||||
import { FeedbackSystem } from './FeedbackSystem';
|
||||
import { calculateColorDirect } from '../../utils/colorModes';
|
||||
import {
|
||||
ValueModeProcessorRegistry,
|
||||
PixelContext,
|
||||
} from './ValueModeProcessor';
|
||||
|
||||
/**
|
||||
* Handles pixel-level rendering operations
|
||||
*/
|
||||
export class PixelRenderer {
|
||||
private feedbackSystem: FeedbackSystem;
|
||||
private shaderContext: ShaderContext;
|
||||
private valueModeRegistry: ValueModeProcessorRegistry;
|
||||
|
||||
constructor(feedbackSystem: FeedbackSystem, shaderContext: ShaderContext) {
|
||||
this.feedbackSystem = feedbackSystem;
|
||||
this.shaderContext = shaderContext;
|
||||
this.valueModeRegistry = ValueModeProcessorRegistry.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single pixel
|
||||
*/
|
||||
renderPixel(
|
||||
data: Uint8ClampedArray,
|
||||
x: number,
|
||||
y: number,
|
||||
actualY: number,
|
||||
width: number,
|
||||
time: number,
|
||||
renderMode: string,
|
||||
valueMode: string,
|
||||
message: WorkerMessage,
|
||||
compiledFunction: ShaderFunction,
|
||||
// Pre-calculated constants
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
_maxDistance: number,
|
||||
invMaxDistance: number,
|
||||
invFullWidth: number,
|
||||
invFullHeight: number,
|
||||
frameCount: number,
|
||||
goldenRatio: number,
|
||||
phase: number,
|
||||
timeTwoPi: number,
|
||||
fullWidthHalf: number,
|
||||
fullHeightHalf: number,
|
||||
deltaTime: number
|
||||
): void {
|
||||
const i = (y * width + x) * 4;
|
||||
const pixelIndex = y * width + x;
|
||||
|
||||
try {
|
||||
// Calculate coordinate variables with optimized math
|
||||
const u = x * invFullWidth;
|
||||
const v = actualY * invFullHeight;
|
||||
|
||||
// Pre-calculate deltas for reuse
|
||||
const dx = x - centerX;
|
||||
const dy = actualY - centerY;
|
||||
|
||||
// Use more efficient radius calculation
|
||||
const radiusSquared = dx * dx + dy * dy;
|
||||
const radius = Math.sqrt(radiusSquared);
|
||||
|
||||
// Optimize angle calculation - avoid atan2 for common cases
|
||||
let angle: number;
|
||||
if (dx === 0) {
|
||||
angle = dy >= 0 ? Math.PI / 2 : -Math.PI / 2;
|
||||
} else if (dy === 0) {
|
||||
angle = dx >= 0 ? 0 : Math.PI;
|
||||
} else {
|
||||
angle = Math.atan2(dy, dx);
|
||||
}
|
||||
|
||||
// Use pre-computed max distance inverse to avoid division
|
||||
const normalizedDistance = radius * invMaxDistance;
|
||||
|
||||
// Optimize Manhattan distance using absolute values of pre-computed deltas
|
||||
const manhattanDistance = Math.abs(dx) + Math.abs(dy);
|
||||
|
||||
// Pre-compute noise factors
|
||||
const sinX01 = Math.sin(x * 0.1);
|
||||
const cosY01 = Math.cos(actualY * 0.1);
|
||||
const noise = (sinX01 * cosY01 + 1) * 0.5;
|
||||
|
||||
// Cache canvas dimensions
|
||||
const canvasWidth = message.fullWidth || width;
|
||||
const canvasHeight = message.fullHeight || message.height! + (message.startY || 0);
|
||||
|
||||
// Get feedback values
|
||||
const feedbackValue = this.feedbackSystem.getFeedback(pixelIndex);
|
||||
const neighbors = this.feedbackSystem.getNeighborFeedback(pixelIndex, x, y, width, canvasHeight);
|
||||
const momentum = this.feedbackSystem.getMomentum(pixelIndex);
|
||||
const laplacian = this.feedbackSystem.getLaplacian(pixelIndex, x, y, width, canvasHeight);
|
||||
const curvature = this.feedbackSystem.getCurvature(pixelIndex, x, y, width, canvasHeight);
|
||||
const stateValue = this.feedbackSystem.getState(pixelIndex, feedbackValue, deltaTime);
|
||||
const echoValue = this.feedbackSystem.getEcho(pixelIndex, time);
|
||||
|
||||
// Calculate other variables
|
||||
const pseudoZ = Math.sin(radius * 0.01 + time) * 50;
|
||||
const jitter = ((x * 73856093 + actualY * 19349663) % 256) / 255;
|
||||
const oscillation = Math.sin(timeTwoPi + radius * 0.1);
|
||||
|
||||
// Calculate block coordinates
|
||||
const bx = x >> 4;
|
||||
const by = actualY >> 4;
|
||||
const sx = x - fullWidthHalf;
|
||||
const sy = actualY - fullHeightHalf;
|
||||
const qx = x >> 3;
|
||||
const qy = actualY >> 3;
|
||||
|
||||
// Populate context object efficiently by reusing existing object
|
||||
const ctx = this.shaderContext;
|
||||
ctx.x = x;
|
||||
ctx.y = actualY;
|
||||
ctx.t = time;
|
||||
ctx.i = pixelIndex;
|
||||
ctx.r = radius;
|
||||
ctx.a = angle;
|
||||
ctx.u = u;
|
||||
ctx.v = v;
|
||||
ctx.c = normalizedDistance;
|
||||
ctx.f = frameCount;
|
||||
ctx.d = manhattanDistance;
|
||||
ctx.n = noise;
|
||||
ctx.b = feedbackValue;
|
||||
ctx.bn = neighbors.north;
|
||||
ctx.bs = neighbors.south;
|
||||
ctx.be = neighbors.east;
|
||||
ctx.bw = neighbors.west;
|
||||
ctx.w = canvasWidth;
|
||||
ctx.h = canvasHeight;
|
||||
ctx.p = phase;
|
||||
ctx.z = pseudoZ;
|
||||
ctx.j = jitter;
|
||||
ctx.o = oscillation;
|
||||
ctx.g = goldenRatio;
|
||||
ctx.m = momentum;
|
||||
ctx.l = laplacian;
|
||||
ctx.k = curvature;
|
||||
ctx.s = stateValue;
|
||||
ctx.e = echoValue;
|
||||
ctx.mouseX = message.mouseX || 0;
|
||||
ctx.mouseY = message.mouseY || 0;
|
||||
ctx.mousePressed = message.mousePressed ? 1 : 0;
|
||||
ctx.mouseVX = message.mouseVX || 0;
|
||||
ctx.mouseVY = message.mouseVY || 0;
|
||||
ctx.mouseClickTime = message.mouseClickTime || 0;
|
||||
ctx.touchCount = message.touchCount || 0;
|
||||
ctx.touch0X = message.touch0X || 0;
|
||||
ctx.touch0Y = message.touch0Y || 0;
|
||||
ctx.touch1X = message.touch1X || 0;
|
||||
ctx.touch1Y = message.touch1Y || 0;
|
||||
ctx.pinchScale = message.pinchScale || 1;
|
||||
ctx.pinchRotation = message.pinchRotation || 0;
|
||||
ctx.accelX = message.accelX || 0;
|
||||
ctx.accelY = message.accelY || 0;
|
||||
ctx.accelZ = message.accelZ || 0;
|
||||
ctx.gyroX = message.gyroX || 0;
|
||||
ctx.gyroY = message.gyroY || 0;
|
||||
ctx.gyroZ = message.gyroZ || 0;
|
||||
ctx.audioLevel = message.audioLevel || 0;
|
||||
ctx.bassLevel = message.bassLevel || 0;
|
||||
ctx.midLevel = message.midLevel || 0;
|
||||
ctx.trebleLevel = message.trebleLevel || 0;
|
||||
ctx.bpm = message.bpm || 120;
|
||||
ctx._t = (mod: number) => time % mod;
|
||||
ctx.bx = bx;
|
||||
ctx.by = by;
|
||||
ctx.sx = sx;
|
||||
ctx.sy = sy;
|
||||
ctx.qx = qx;
|
||||
ctx.qy = qy;
|
||||
|
||||
// Execute shader
|
||||
const value = compiledFunction(ctx);
|
||||
const safeValue = isFinite(value) ? value : 0;
|
||||
|
||||
// Calculate color
|
||||
const color = this.calculateColor(
|
||||
safeValue,
|
||||
renderMode,
|
||||
valueMode,
|
||||
message.hueShift || 0,
|
||||
x,
|
||||
actualY,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
|
||||
// Set pixel data
|
||||
data[i] = color[0];
|
||||
data[i + 1] = color[1];
|
||||
data[i + 2] = color[2];
|
||||
data[i + 3] = 255;
|
||||
|
||||
// Update feedback system
|
||||
this.feedbackSystem.updateFeedback(pixelIndex, color[0], color[1], color[2], deltaTime);
|
||||
this.feedbackSystem.updateState(pixelIndex, stateValue);
|
||||
|
||||
} catch (error) {
|
||||
// Fill with black on error
|
||||
data[i] = 0;
|
||||
data[i + 1] = 0;
|
||||
data[i + 2] = 0;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates color from shader value
|
||||
*/
|
||||
private calculateColor(
|
||||
value: number,
|
||||
renderMode: string,
|
||||
valueMode: string = 'integer',
|
||||
hueShift: number = 0,
|
||||
x: number = 0,
|
||||
y: number = 0,
|
||||
width: number = 1,
|
||||
height: number = 1
|
||||
): [number, number, number] {
|
||||
// Use optimized strategy pattern for ALL modes
|
||||
const context: PixelContext = { x, y, width, height, value };
|
||||
const processor = this.valueModeRegistry.getProcessor(valueMode);
|
||||
let processedValue: number;
|
||||
|
||||
if (processor) {
|
||||
const precomputed = ValueModeProcessorRegistry.precomputeContext(context);
|
||||
processedValue = processor(context, precomputed);
|
||||
} else {
|
||||
// Fallback for unknown modes
|
||||
processedValue = Math.abs(value) % 256;
|
||||
}
|
||||
|
||||
return calculateColorDirect(processedValue, renderMode, hueShift);
|
||||
}
|
||||
|
||||
}
|
||||
872
src/shader/rendering/ValueModeProcessor.ts
Normal file
872
src/shader/rendering/ValueModeProcessor.ts
Normal file
@ -0,0 +1,872 @@
|
||||
import { RGB, MATH } from '../../utils/constants';
|
||||
|
||||
export interface PixelContext {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface PrecomputedContext {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
distance: number;
|
||||
angle: number;
|
||||
normalizedDistance: number;
|
||||
normalizedAngle: number;
|
||||
}
|
||||
|
||||
export type ValueModeProcessor = (
|
||||
context: PixelContext,
|
||||
precomputed: PrecomputedContext
|
||||
) => number;
|
||||
|
||||
export class ValueModeProcessorRegistry {
|
||||
private static instance: ValueModeProcessorRegistry;
|
||||
private processors: Map<string, ValueModeProcessor> = new Map();
|
||||
|
||||
private constructor() {
|
||||
this.initializeProcessors();
|
||||
}
|
||||
|
||||
static getInstance(): ValueModeProcessorRegistry {
|
||||
if (!ValueModeProcessorRegistry.instance) {
|
||||
ValueModeProcessorRegistry.instance = new ValueModeProcessorRegistry();
|
||||
}
|
||||
return ValueModeProcessorRegistry.instance;
|
||||
}
|
||||
|
||||
getProcessor(mode: string): ValueModeProcessor | undefined {
|
||||
return this.processors.get(mode);
|
||||
}
|
||||
|
||||
private initializeProcessors(): void {
|
||||
this.processors.set('integer', this.integerMode);
|
||||
this.processors.set('float', this.floatMode);
|
||||
this.processors.set('polar', this.polarMode);
|
||||
this.processors.set('distance', this.distanceMode);
|
||||
this.processors.set('wave', this.waveMode);
|
||||
this.processors.set('fractal', this.fractalMode);
|
||||
this.processors.set('cellular', this.cellularMode);
|
||||
this.processors.set('noise', this.noiseMode);
|
||||
this.processors.set('warp', this.warpMode);
|
||||
this.processors.set('flow', this.flowMode);
|
||||
this.processors.set('spiral', this.spiralMode);
|
||||
this.processors.set('turbulence', this.turbulenceMode);
|
||||
this.processors.set('crystal', this.crystalMode);
|
||||
this.processors.set('marble', this.marbleMode);
|
||||
this.processors.set('quantum', this.quantumMode);
|
||||
this.processors.set('logarithmic', this.logarithmicMode);
|
||||
this.processors.set('mirror', this.mirrorMode);
|
||||
this.processors.set('rings', this.ringsMode);
|
||||
this.processors.set('mesh', this.meshMode);
|
||||
this.processors.set('glitch', this.glitchMode);
|
||||
this.processors.set('diffusion', this.diffusionMode);
|
||||
this.processors.set('cascade', this.cascadeMode);
|
||||
this.processors.set('echo', this.echoMode);
|
||||
this.processors.set('mosh', this.moshMode);
|
||||
this.processors.set('fold', this.foldMode);
|
||||
}
|
||||
|
||||
static precomputeContext(context: PixelContext): PrecomputedContext {
|
||||
const centerX = context.width * 0.5;
|
||||
const centerY = context.height * 0.5;
|
||||
const dx = context.x - centerX;
|
||||
const dy = context.y - centerY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY);
|
||||
const normalizedDistance = distance / maxDistance;
|
||||
const normalizedAngle = (angle + Math.PI) / MATH.TWO_PI;
|
||||
|
||||
return {
|
||||
centerX,
|
||||
centerY,
|
||||
dx,
|
||||
dy,
|
||||
distance,
|
||||
angle,
|
||||
normalizedDistance,
|
||||
normalizedAngle,
|
||||
};
|
||||
}
|
||||
|
||||
private integerMode = (context: PixelContext): number => {
|
||||
return Math.abs(context.value);
|
||||
};
|
||||
|
||||
private floatMode = (context: PixelContext): number => {
|
||||
let processedValue = Math.max(0, Math.min(1, Math.abs(context.value)));
|
||||
processedValue = 1 - processedValue;
|
||||
return Math.floor(processedValue * RGB.MAX_VALUE);
|
||||
};
|
||||
|
||||
private polarMode = (
|
||||
context: PixelContext,
|
||||
precomputed: PrecomputedContext
|
||||
): number => {
|
||||
const radiusNorm = precomputed.normalizedDistance;
|
||||
const spiralEffect =
|
||||
(precomputed.normalizedAngle + radiusNorm * 0.5 + Math.abs(context.value) * 0.02) % 1;
|
||||
const polarValue = Math.sin(spiralEffect * Math.PI * 8) * 0.5 + 0.5;
|
||||
return Math.floor(polarValue * RGB.MAX_VALUE);
|
||||
};
|
||||
|
||||
private distanceMode = (
|
||||
context: PixelContext,
|
||||
precomputed: PrecomputedContext
|
||||
): number => {
|
||||
const frequency = 8 + Math.abs(context.value) * 0.1;
|
||||
const phase = Math.abs(context.value) * 0.05;
|
||||
const concentricWave =
|
||||
Math.sin(precomputed.normalizedDistance * Math.PI * frequency + phase) * 0.5 + 0.5;
|
||||
const falloff = 1 - Math.pow(precomputed.normalizedDistance, 0.8);
|
||||
const distanceValue = concentricWave * falloff;
|
||||
return Math.floor(distanceValue * RGB.MAX_VALUE);
|
||||
};
|
||||
|
||||
private waveMode = (
|
||||
context: PixelContext,
|
||||
precomputed: PrecomputedContext
|
||||
): number => {
|
||||
const baseFreq = 0.08;
|
||||
const valueScale = Math.abs(context.value) * 0.001 + 1;
|
||||
let waveSum = 0;
|
||||
|
||||
const sources = WaveConstants.SOURCES;
|
||||
const scaledSources = sources.map(source => ({
|
||||
x: context.width * source.x,
|
||||
y: context.height * source.y,
|
||||
}));
|
||||
|
||||
for (const source of scaledSources) {
|
||||
const dx = context.x - source.x;
|
||||
const dy = context.y - source.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const wave = Math.sin(dist * baseFreq * valueScale + Math.abs(context.value) * 0.02);
|
||||
const amplitude = 1 / (1 + dist * 0.002);
|
||||
waveSum += wave * amplitude;
|
||||
}
|
||||
|
||||
const waveValue = Math.tanh(waveSum) * 0.5 + 0.5;
|
||||
return Math.floor(waveValue * RGB.MAX_VALUE);
|
||||
};
|
||||
|
||||
private fractalMode = (context: PixelContext, _precomputed: PrecomputedContext): number => {
|
||||
const scale = 0.01;
|
||||
let fractalValue = 0;
|
||||
let frequency = 1;
|
||||
let amplitude = 1;
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const nx = context.x * scale * frequency + Math.abs(context.value) * 0.01;
|
||||
const ny = context.y * scale * frequency;
|
||||
const noise = this.simplexNoise(nx, ny);
|
||||
fractalValue += noise * amplitude;
|
||||
frequency *= 2;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
|
||||
fractalValue = (fractalValue + 1) * 0.5;
|
||||
return Math.floor(fractalValue * RGB.MAX_VALUE);
|
||||
};
|
||||
|
||||
private cellularMode = (context: PixelContext): number => {
|
||||
const cellSize = 8;
|
||||
const cellX = Math.floor(context.x / cellSize);
|
||||
const cellY = Math.floor(context.y / cellSize);
|
||||
let liveNeighbors = 0;
|
||||
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
const nx = cellX + dx;
|
||||
const ny = cellY + dy;
|
||||
const neighborValue = Math.abs(
|
||||
this.pseudoRandom(nx * 73856093 + ny * 19349663 + Math.floor(Math.abs(context.value)))
|
||||
);
|
||||
if (neighborValue > 0.5) liveNeighbors++;
|
||||
}
|
||||
}
|
||||
|
||||
const cellValue = liveNeighbors >= 4 ? 1 : 0;
|
||||
return Math.floor(cellValue * RGB.MAX_VALUE);
|
||||
};
|
||||
|
||||
private simplexNoise(x: number, y: number): number {
|
||||
const F2 = 0.5 * (Math.sqrt(3) - 1);
|
||||
const G2 = (3 - Math.sqrt(3)) / 6;
|
||||
|
||||
const s = (x + y) * F2;
|
||||
const i = Math.floor(x + s);
|
||||
const j = Math.floor(y + s);
|
||||
|
||||
const t = (i + j) * G2;
|
||||
const X0 = i - t;
|
||||
const Y0 = j - t;
|
||||
const x0 = x - X0;
|
||||
const y0 = y - Y0;
|
||||
|
||||
const i1 = x0 > y0 ? 1 : 0;
|
||||
const j1 = x0 > y0 ? 0 : 1;
|
||||
|
||||
const x1 = x0 - i1 + G2;
|
||||
const y1 = y0 - j1 + G2;
|
||||
const x2 = x0 - 1 + 2 * G2;
|
||||
const y2 = y0 - 1 + 2 * G2;
|
||||
|
||||
const ii = i & 255;
|
||||
const jj = j & 255;
|
||||
|
||||
const gi0 = this.permMod12[ii + this.perm[jj]] % 12;
|
||||
const gi1 = this.permMod12[ii + i1 + this.perm[jj + j1]] % 12;
|
||||
const gi2 = this.permMod12[ii + 1 + this.perm[jj + 1]] % 12;
|
||||
|
||||
let n0, n1, n2;
|
||||
|
||||
let t0 = 0.5 - x0 * x0 - y0 * y0;
|
||||
if (t0 < 0) n0 = 0;
|
||||
else {
|
||||
t0 *= t0;
|
||||
n0 = t0 * t0 * this.dot(this.grad3[gi0], x0, y0);
|
||||
}
|
||||
|
||||
let t1 = 0.5 - x1 * x1 - y1 * y1;
|
||||
if (t1 < 0) n1 = 0;
|
||||
else {
|
||||
t1 *= t1;
|
||||
n1 = t1 * t1 * this.dot(this.grad3[gi1], x1, y1);
|
||||
}
|
||||
|
||||
let t2 = 0.5 - x2 * x2 - y2 * y2;
|
||||
if (t2 < 0) n2 = 0;
|
||||
else {
|
||||
t2 *= t2;
|
||||
n2 = t2 * t2 * this.dot(this.grad3[gi2], x2, y2);
|
||||
}
|
||||
|
||||
return 70 * (n0 + n1 + n2);
|
||||
}
|
||||
|
||||
private pseudoRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
private dot(g: number[], x: number, y: number): number {
|
||||
return g[0] * x + g[1] * y;
|
||||
}
|
||||
|
||||
private grad3 = [
|
||||
[1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0],
|
||||
[1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1],
|
||||
[0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1]
|
||||
];
|
||||
|
||||
private perm = [
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225,
|
||||
140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148,
|
||||
247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32,
|
||||
57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175,
|
||||
74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122,
|
||||
60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54,
|
||||
65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169,
|
||||
200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64,
|
||||
52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212,
|
||||
207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213,
|
||||
119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
|
||||
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104,
|
||||
218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241,
|
||||
81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
|
||||
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93,
|
||||
222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180,
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225,
|
||||
140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148,
|
||||
247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32,
|
||||
57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175,
|
||||
74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122,
|
||||
60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54,
|
||||
65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169,
|
||||
200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64,
|
||||
52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212,
|
||||
207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213,
|
||||
119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
|
||||
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104,
|
||||
218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241,
|
||||
81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
|
||||
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93,
|
||||
222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180
|
||||
];
|
||||
|
||||
private permMod12 = this.perm.map(p => p % 12);
|
||||
|
||||
private noiseMode = (context: PixelContext): number => {
|
||||
const noiseScale = 0.02;
|
||||
const nx = context.x * noiseScale + Math.abs(context.value) * 0.001;
|
||||
const ny = context.y * noiseScale + Math.abs(context.value) * 0.001;
|
||||
|
||||
const noise1 = Math.sin(nx * 6.28) * Math.cos(ny * 6.28);
|
||||
const noise2 = Math.sin(nx * 12.56) * Math.cos(ny * 12.56) * 0.5;
|
||||
const noise3 = Math.sin(nx * 25.12) * Math.cos(ny * 25.12) * 0.25;
|
||||
|
||||
const combinedNoise = (noise1 + noise2 + noise3) / 1.75;
|
||||
return Math.floor((combinedNoise + 1) * 0.5 * RGB.MAX_VALUE);
|
||||
};
|
||||
private warpMode = (context: PixelContext, precomputed: PrecomputedContext): number => {
|
||||
const warpStrength = Math.abs(context.value) * 0.001;
|
||||
const warpFreq = 0.02;
|
||||
|
||||
const warpX = context.x + Math.sin(context.y * warpFreq + Math.abs(context.value) * 0.01) * warpStrength * 100;
|
||||
const warpY = context.y + Math.cos(context.x * warpFreq + Math.abs(context.value) * 0.01) * warpStrength * 100;
|
||||
|
||||
const dx = warpX - precomputed.centerX;
|
||||
const dy = warpY - precomputed.centerY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const maxDist = Math.sqrt(precomputed.centerX * precomputed.centerX + precomputed.centerY * precomputed.centerY);
|
||||
const normDist = dist / maxDist;
|
||||
|
||||
const deform = 1 + Math.sin(normDist * Math.PI + Math.abs(context.value) * 0.05) * 0.3;
|
||||
const deformedX = precomputed.centerX + dx * deform;
|
||||
const deformedY = precomputed.centerY + dy * deform;
|
||||
|
||||
const finalValue = (deformedX + deformedY + Math.abs(context.value)) % 256;
|
||||
return Math.floor(Math.abs(finalValue));
|
||||
};
|
||||
private flowMode = (context: PixelContext, precomputed: PrecomputedContext): number => {
|
||||
const flowSources = [
|
||||
{
|
||||
x: precomputed.centerX + Math.sin(Math.abs(context.value) * 0.01) * 200,
|
||||
y: precomputed.centerY + Math.cos(Math.abs(context.value) * 0.01) * 200,
|
||||
strength: 1 + Math.abs(context.value) * 0.01,
|
||||
},
|
||||
{
|
||||
x: precomputed.centerX + Math.cos(Math.abs(context.value) * 0.015) * 150,
|
||||
y: precomputed.centerY + Math.sin(Math.abs(context.value) * 0.015) * 150,
|
||||
strength: -0.8 + Math.sin(Math.abs(context.value) * 0.02) * 0.5,
|
||||
},
|
||||
{
|
||||
x: precomputed.centerX + Math.sin(Math.abs(context.value) * 0.008) * 300,
|
||||
y: precomputed.centerY + Math.cos(Math.abs(context.value) * 0.012) * 250,
|
||||
strength: 0.6 + Math.cos(Math.abs(context.value) * 0.018) * 0.4,
|
||||
},
|
||||
];
|
||||
|
||||
let flowX = 0;
|
||||
let flowY = 0;
|
||||
|
||||
for (const source of flowSources) {
|
||||
const dx = context.x - source.x;
|
||||
const dy = context.y - source.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const normalizedDist = Math.max(distance, 1);
|
||||
|
||||
const flowStrength = source.strength / (normalizedDist * 0.01);
|
||||
flowX += (dx / normalizedDist) * flowStrength;
|
||||
flowY += (dy / normalizedDist) * flowStrength;
|
||||
|
||||
const curlStrength = source.strength * 0.5;
|
||||
flowX += ((-dy / normalizedDist) * curlStrength) / normalizedDist;
|
||||
flowY += ((dx / normalizedDist) * curlStrength) / normalizedDist;
|
||||
}
|
||||
|
||||
const globalFlowAngle = Math.abs(context.value) * 0.02;
|
||||
flowX += Math.cos(globalFlowAngle) * (Math.abs(context.value) * 0.1);
|
||||
flowY += Math.sin(globalFlowAngle) * (Math.abs(context.value) * 0.1);
|
||||
|
||||
const turbScale = 0.05;
|
||||
const turbulence = Math.sin(context.x * turbScale + Math.abs(context.value) * 0.01) *
|
||||
Math.cos(context.y * turbScale + Math.abs(context.value) * 0.015) *
|
||||
(Math.abs(context.value) * 0.02);
|
||||
|
||||
flowX += turbulence;
|
||||
flowY += turbulence * 0.7;
|
||||
|
||||
let particleX = context.x;
|
||||
let particleY = context.y;
|
||||
|
||||
for (let step = 0; step < 5; step++) {
|
||||
let localFlowX = 0;
|
||||
let localFlowY = 0;
|
||||
|
||||
for (const source of flowSources) {
|
||||
const dx = particleX - source.x;
|
||||
const dy = particleY - source.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const normalizedDist = Math.max(distance, 1);
|
||||
|
||||
const flowStrength = source.strength / (normalizedDist * 0.01);
|
||||
localFlowX += (dx / normalizedDist) * flowStrength;
|
||||
localFlowY += (dy / normalizedDist) * flowStrength;
|
||||
|
||||
const curlStrength = source.strength * 0.5;
|
||||
localFlowX += ((-dy / normalizedDist) * curlStrength) / normalizedDist;
|
||||
localFlowY += ((dx / normalizedDist) * curlStrength) / normalizedDist;
|
||||
}
|
||||
|
||||
const stepSize = 0.5;
|
||||
particleX += localFlowX * stepSize;
|
||||
particleY += localFlowY * stepSize;
|
||||
}
|
||||
|
||||
const flowMagnitude = Math.sqrt(flowX * flowX + flowY * flowY);
|
||||
const particleDistance = Math.sqrt(
|
||||
(particleX - context.x) * (particleX - context.x) + (particleY - context.y) * (particleY - context.y)
|
||||
);
|
||||
|
||||
const flowValue = (flowMagnitude * 10 + particleDistance * 2) % 256;
|
||||
const enhanced = Math.sin(flowValue * 0.05 + Math.abs(context.value) * 0.01) * 0.5 + 0.5;
|
||||
|
||||
return Math.floor(enhanced * RGB.MAX_VALUE);
|
||||
};
|
||||
private spiralMode = (context: PixelContext, precomputed: PrecomputedContext): number => {
|
||||
const spiralTightness = 1 + Math.abs(context.value) * 0.01;
|
||||
const spiralValue = precomputed.angle + Math.log(Math.max(precomputed.distance, 1)) * spiralTightness;
|
||||
return Math.floor((Math.sin(spiralValue) * 0.5 + 0.5) * RGB.MAX_VALUE);
|
||||
};
|
||||
private turbulenceMode = (context: PixelContext): number => {
|
||||
let turbulence = 0;
|
||||
const chaos = Math.abs(context.value) * 0.001;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const freq = Math.pow(2, i) * (0.01 + chaos);
|
||||
turbulence += Math.abs(Math.sin(context.x * freq) * Math.cos(context.y * freq)) / Math.pow(2, i);
|
||||
}
|
||||
return Math.floor(Math.min(turbulence, 1) * RGB.MAX_VALUE);
|
||||
};
|
||||
private crystalMode = (context: PixelContext): number => {
|
||||
const latticeSize = 32 + Math.abs(context.value) * 0.1;
|
||||
const gridX = Math.floor(context.x / latticeSize);
|
||||
const gridY = Math.floor(context.y / latticeSize);
|
||||
const crystal = Math.sin(gridX + gridY + Math.abs(context.value) * 0.01) *
|
||||
Math.cos(gridX * gridY + Math.abs(context.value) * 0.005);
|
||||
return Math.floor((crystal * 0.5 + 0.5) * RGB.MAX_VALUE);
|
||||
};
|
||||
private marbleMode = (context: PixelContext): number => {
|
||||
const noiseFreq = 0.005 + Math.abs(context.value) * 0.00001;
|
||||
const turbulence = Math.sin(context.x * noiseFreq) * Math.cos(context.y * noiseFreq) +
|
||||
Math.sin(context.x * noiseFreq * 2) * Math.cos(context.y * noiseFreq * 2) * 0.5;
|
||||
const marble = Math.sin((context.x + turbulence * 50) * 0.02 + Math.abs(context.value) * 0.001);
|
||||
return Math.floor((marble * 0.5 + 0.5) * RGB.MAX_VALUE);
|
||||
};
|
||||
private quantumMode = (context: PixelContext, precomputed: PrecomputedContext): number => {
|
||||
const uncertainty = Math.abs(context.value) * 0.001;
|
||||
const distSquared = precomputed.dx * precomputed.dx + precomputed.dy * precomputed.dy;
|
||||
const sigmaSquared = (100 + uncertainty * 1000);
|
||||
const probability = Math.exp(-distSquared / (2 * sigmaSquared * sigmaSquared));
|
||||
const quantum = probability * (1 + Math.sin(context.x * context.y * uncertainty) * 0.5);
|
||||
return Math.floor(Math.min(quantum, 1) * RGB.MAX_VALUE);
|
||||
};
|
||||
private logarithmicMode = (context: PixelContext): number => {
|
||||
const logValue = Math.log(1 + Math.abs(context.value));
|
||||
return Math.floor((logValue / Math.log(256)) * RGB.MAX_VALUE);
|
||||
};
|
||||
private mirrorMode = (context: PixelContext, precomputed: PrecomputedContext): number => {
|
||||
const dx = Math.abs(context.x - precomputed.centerX);
|
||||
const dy = Math.abs(context.y - precomputed.centerY);
|
||||
const mirrorX = precomputed.centerX + (dx % precomputed.centerX);
|
||||
const mirrorY = precomputed.centerY + (dy % precomputed.centerY);
|
||||
const mirrorDistance = Math.sqrt(mirrorX * mirrorX + mirrorY * mirrorY);
|
||||
const mirrorValue = (Math.abs(context.value) + mirrorDistance) % 256;
|
||||
return mirrorValue;
|
||||
};
|
||||
private ringsMode = (context: PixelContext, precomputed: PrecomputedContext): number => {
|
||||
const ringSpacing = 20 + Math.abs(context.value) * 0.1;
|
||||
const rings = Math.sin((precomputed.distance / ringSpacing) * Math.PI * 2);
|
||||
const interference = Math.sin((precomputed.distance + Math.abs(context.value)) * 0.05);
|
||||
return Math.floor(((rings * interference) * 0.5 + 0.5) * RGB.MAX_VALUE);
|
||||
};
|
||||
private meshMode = (context: PixelContext): number => {
|
||||
const angle = Math.abs(context.value) * 0.001;
|
||||
const rotX = context.x * Math.cos(angle) - context.y * Math.sin(angle);
|
||||
const rotY = context.x * Math.sin(angle) + context.y * Math.cos(angle);
|
||||
const gridSize = 16 + Math.abs(context.value) * 0.05;
|
||||
const gridX = Math.sin((rotX / gridSize) * Math.PI * 2);
|
||||
const gridY = Math.sin((rotY / gridSize) * Math.PI * 2);
|
||||
const mesh = Math.max(Math.abs(gridX), Math.abs(gridY));
|
||||
return Math.floor(mesh * RGB.MAX_VALUE);
|
||||
};
|
||||
private glitchMode = (context: PixelContext): number => {
|
||||
const seed = Math.floor(context.x + context.y * context.width + Math.abs(context.value));
|
||||
const random = ((seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
|
||||
const glitchThreshold = 0.95 - Math.abs(context.value) * 0.0001;
|
||||
let glitchValue = Math.abs(context.value) % 256;
|
||||
|
||||
if (random > glitchThreshold) {
|
||||
glitchValue = (glitchValue << 1) ^ (glitchValue >> 3) ^ ((context.x + context.y) & 0xFF);
|
||||
}
|
||||
|
||||
return glitchValue % 256;
|
||||
};
|
||||
private diffusionMode = (context: PixelContext): number => {
|
||||
const diffusionRate = 0.1 + Math.abs(context.value) * 0.0001;
|
||||
const kernelSize = 3;
|
||||
const halfKernel = Math.floor(kernelSize / 2);
|
||||
|
||||
const heatSource = Math.abs(context.value) * 0.01;
|
||||
let totalHeat = heatSource;
|
||||
let sampleCount = 1;
|
||||
|
||||
for (let dy = -halfKernel; dy <= halfKernel; dy++) {
|
||||
for (let dx = -halfKernel; dx <= halfKernel; dx++) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
|
||||
const neighborX = context.x + dx;
|
||||
const neighborY = context.y + dy;
|
||||
|
||||
if (neighborX >= 0 && neighborX < context.width && neighborY >= 0 && neighborY < context.height) {
|
||||
const neighborSeed = neighborX + neighborY * context.width;
|
||||
const neighborHeat = ((neighborSeed * 1103515245 + 12345) % 256) / 256;
|
||||
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const weight = Math.exp(-distance * distance * 0.5);
|
||||
|
||||
totalHeat += neighborHeat * weight * diffusionRate;
|
||||
sampleCount += weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const averageHeat = totalHeat / sampleCount;
|
||||
const decay = 0.95 + Math.sin(Math.abs(context.value) * 0.01) * 0.04;
|
||||
|
||||
const convectionX = Math.sin(context.x * 0.01 + Math.abs(context.value) * 0.001) * 0.1;
|
||||
const convectionY = Math.cos(context.y * 0.01 + Math.abs(context.value) * 0.001) * 0.1;
|
||||
const convection = (convectionX + convectionY) * 0.5 + 0.5;
|
||||
|
||||
const finalHeat = (averageHeat * decay + convection * 0.3) % 1;
|
||||
const enhancedHeat = Math.pow(finalHeat, 1.2);
|
||||
|
||||
return Math.floor(enhancedHeat * RGB.MAX_VALUE);
|
||||
};
|
||||
private cascadeMode = (context: PixelContext, precomputed: PrecomputedContext): number => {
|
||||
const triggerPoints = [
|
||||
{
|
||||
x: precomputed.centerX + Math.sin(Math.abs(context.value) * 0.01) * 150,
|
||||
y: precomputed.centerY + Math.cos(Math.abs(context.value) * 0.01) * 150,
|
||||
threshold: 100 + Math.abs(context.value) * 0.05,
|
||||
strength: 1.0 + Math.abs(context.value) * 0.001
|
||||
},
|
||||
{
|
||||
x: precomputed.centerX + Math.cos(Math.abs(context.value) * 0.015) * 200,
|
||||
y: precomputed.centerY + Math.sin(Math.abs(context.value) * 0.018) * 120,
|
||||
threshold: 80 + Math.abs(context.value) * 0.08,
|
||||
strength: 0.8 + Math.sin(Math.abs(context.value) * 0.02) * 0.4
|
||||
},
|
||||
{
|
||||
x: precomputed.centerX + Math.sin(Math.abs(context.value) * 0.012) * 180,
|
||||
y: precomputed.centerY + Math.cos(Math.abs(context.value) * 0.008) * 160,
|
||||
threshold: 120 + Math.abs(context.value) * 0.03,
|
||||
strength: 0.6 + Math.cos(Math.abs(context.value) * 0.025) * 0.3
|
||||
}
|
||||
];
|
||||
|
||||
let cascadeValue = 0;
|
||||
const baseValue = Math.abs(context.value) % 256;
|
||||
|
||||
for (const trigger of triggerPoints) {
|
||||
const dx = context.x - trigger.x;
|
||||
const dy = context.y - trigger.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const maxDistance = Math.sqrt(context.width * context.width + context.height * context.height);
|
||||
const normalizedDistance = distance / maxDistance;
|
||||
|
||||
if (baseValue > trigger.threshold) {
|
||||
const waveFreq = 0.1 + Math.abs(context.value) * 0.0001;
|
||||
const wave = Math.sin(distance * waveFreq + Math.abs(context.value) * 0.02);
|
||||
|
||||
const amplitude = trigger.strength * Math.exp(-distance * distance * 0.000001);
|
||||
const cascadeWave = wave * amplitude;
|
||||
|
||||
const perpWave = Math.cos(distance * waveFreq * 1.3 + Math.abs(context.value) * 0.015);
|
||||
const interference = cascadeWave + perpWave * amplitude * 0.3;
|
||||
|
||||
cascadeValue += interference;
|
||||
}
|
||||
}
|
||||
|
||||
let turbulence = 0;
|
||||
const turbFreq = 0.02 + Math.abs(context.value) * 0.00005;
|
||||
|
||||
for (let octave = 0; octave < 4; octave++) {
|
||||
const freq = turbFreq * Math.pow(2, octave);
|
||||
const amplitude = 1.0 / Math.pow(2, octave);
|
||||
|
||||
turbulence += Math.sin(context.x * freq + Math.abs(context.value) * 0.01) *
|
||||
Math.cos(context.y * freq + Math.abs(context.value) * 0.012) * amplitude;
|
||||
}
|
||||
|
||||
const combinedValue = baseValue + cascadeValue * 50 + turbulence * 30;
|
||||
let finalValue = combinedValue;
|
||||
|
||||
const thresholds = [150, 100, 200, 175];
|
||||
for (const threshold of thresholds) {
|
||||
if (Math.abs(combinedValue) > threshold) {
|
||||
const amplification = (Math.abs(combinedValue) - threshold) * 0.5;
|
||||
finalValue += amplification;
|
||||
|
||||
const distortionX = Math.sin(context.y * 0.05 + Math.abs(context.value) * 0.01) * amplification * 0.1;
|
||||
const distortionY = Math.cos(context.x * 0.05 + Math.abs(context.value) * 0.015) * amplification * 0.1;
|
||||
|
||||
finalValue += distortionX + distortionY;
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = Math.sin(finalValue * 0.02 + Math.abs(context.value) * 0.005) * 20;
|
||||
finalValue += feedback;
|
||||
|
||||
const nonLinear = Math.tanh(finalValue * 0.01) * 128 + 128;
|
||||
|
||||
const edgeDetection = Math.abs(
|
||||
Math.sin(context.x * 0.1 + Math.abs(context.value) * 0.001) -
|
||||
Math.sin(context.y * 0.1 + Math.abs(context.value) * 0.001)
|
||||
) * 30;
|
||||
|
||||
return Math.floor(Math.max(0, Math.min(RGB.MAX_VALUE, nonLinear + edgeDetection)));
|
||||
};
|
||||
private echoMode = (context: PixelContext): number => {
|
||||
const baseValue = Math.abs(context.value) % 256;
|
||||
|
||||
const echoSources = [
|
||||
{ delay: 0.1, decay: 0.8, spatial: 0.02, twist: 1.0 },
|
||||
{ delay: 0.25, decay: 0.6, spatial: 0.05, twist: -0.7 },
|
||||
{ delay: 0.4, decay: 0.45, spatial: 0.03, twist: 1.3 },
|
||||
{ delay: 0.6, decay: 0.3, spatial: 0.08, twist: -0.9 }
|
||||
];
|
||||
|
||||
let echoSum = baseValue;
|
||||
let totalWeight = 1.0;
|
||||
|
||||
for (const echo of echoSources) {
|
||||
const spatialOffsetX = Math.sin(context.x * echo.spatial + echo.twist) * echo.delay * 100;
|
||||
const spatialOffsetY = Math.cos(context.y * echo.spatial + echo.twist * 0.7) * echo.delay * 80;
|
||||
|
||||
const offsetX = context.x + spatialOffsetX;
|
||||
const offsetY = context.y + spatialOffsetY;
|
||||
|
||||
const offsetSeed = Math.floor(offsetX) + Math.floor(offsetY) * context.width;
|
||||
const offsetNoise = ((offsetSeed * 1103515245 + 12345) % 256) / 256;
|
||||
const delayedValue = (baseValue * (1 - echo.delay) + offsetNoise * 255 * echo.delay) % 256;
|
||||
|
||||
const harmonic = Math.sin(delayedValue * 0.05 + echo.twist) * 30;
|
||||
const distortedEcho = delayedValue + harmonic;
|
||||
|
||||
const feedback = Math.sin(distortedEcho * 0.02 + context.x * 0.01 + context.y * 0.01) * echo.decay * 40;
|
||||
const finalEcho = distortedEcho + feedback;
|
||||
|
||||
echoSum += finalEcho * echo.decay;
|
||||
totalWeight += echo.decay;
|
||||
}
|
||||
|
||||
const interference = Math.sin(echoSum * 0.03) * Math.cos(baseValue * 0.04) * 25;
|
||||
echoSum += interference;
|
||||
|
||||
const compressed = Math.tanh(echoSum / totalWeight * 0.02) * RGB.MAX_VALUE;
|
||||
|
||||
return Math.floor(Math.max(0, Math.min(RGB.MAX_VALUE, compressed)));
|
||||
};
|
||||
private moshMode = (context: PixelContext): number => {
|
||||
const baseValue = Math.abs(context.value) % 256;
|
||||
|
||||
const pseudoTime = (context.x + context.y + baseValue) * 0.1;
|
||||
const temporalDrift = Math.floor(pseudoTime * 100) % 1024;
|
||||
const microJitter = Math.sin(pseudoTime * 20) * 0.8;
|
||||
|
||||
const blockSize = 8;
|
||||
const driftedX = context.x + Math.sin(temporalDrift * 0.01 + context.y * 0.1) * microJitter;
|
||||
const driftedY = context.y + Math.cos(temporalDrift * 0.008 + context.x * 0.12) * microJitter;
|
||||
|
||||
const blockX = Math.floor(driftedX / blockSize);
|
||||
const blockY = Math.floor(driftedY / blockSize);
|
||||
const blockId = blockX + blockY * Math.floor(context.width / blockSize);
|
||||
|
||||
const corruptionLevel = (baseValue / 255.0) * 0.8 + 0.1;
|
||||
const blockSeed = blockId * 1103515245 + baseValue + temporalDrift;
|
||||
const blockRandom = ((blockSeed % 65536) / 65536);
|
||||
|
||||
let corruptedValue = baseValue;
|
||||
|
||||
if (blockRandom < corruptionLevel * 0.3) {
|
||||
const motionX = ((blockSeed >> 8) & 7) - 4 + Math.sin(temporalDrift * 0.02) * 0.5;
|
||||
const motionY = ((blockSeed >> 11) & 7) - 4 + Math.cos(temporalDrift * 0.018) * 0.5;
|
||||
|
||||
const sourceX = context.x + motionX * 2;
|
||||
const sourceY = context.y + motionY * 2;
|
||||
|
||||
if (sourceX >= 0 && sourceX < context.width && sourceY >= 0 && sourceY < context.height) {
|
||||
const sourceSeed = Math.floor(sourceX) + Math.floor(sourceY) * context.width;
|
||||
const sourceNoise = ((sourceSeed * 1664525 + 1013904223) % 256) / 256;
|
||||
corruptedValue = Math.floor(sourceNoise * 255);
|
||||
}
|
||||
}
|
||||
else if (blockRandom < corruptionLevel * 0.6) {
|
||||
const localX = context.x % blockSize;
|
||||
const localY = context.y % blockSize;
|
||||
|
||||
const dctFreqX = Math.floor(localX / 2);
|
||||
const dctFreqY = Math.floor(localY / 2);
|
||||
const dctCoeff = Math.sin((dctFreqX + dctFreqY) * Math.PI / 4);
|
||||
|
||||
const corruption = dctCoeff * (blockRandom - 0.5) * 100;
|
||||
corruptedValue = Math.floor(baseValue + corruption);
|
||||
}
|
||||
else if (blockRandom < corruptionLevel * 0.8) {
|
||||
const bleedIntensity = (blockRandom - 0.6) * 5;
|
||||
const temporalPhase = temporalDrift * 0.03;
|
||||
const bleedX = Math.sin(context.x * 0.1 + baseValue * 0.05 + temporalPhase) * bleedIntensity;
|
||||
const bleedY = Math.cos(context.y * 0.1 + baseValue * 0.03 + temporalPhase * 0.8) * bleedIntensity;
|
||||
|
||||
corruptedValue = Math.floor(baseValue + bleedX + bleedY);
|
||||
}
|
||||
else if (blockRandom < corruptionLevel) {
|
||||
const quantLevels = 8 + Math.floor((1 - corruptionLevel) * 16);
|
||||
const quantStep = 256 / quantLevels;
|
||||
corruptedValue = Math.floor(baseValue / quantStep) * quantStep;
|
||||
|
||||
const quantNoise = ((blockSeed >> 16) & 15) - 8;
|
||||
corruptedValue += quantNoise;
|
||||
}
|
||||
|
||||
if (blockRandom > 0.95) {
|
||||
const tempSeed = context.x * 73856093 + context.y * 19349663 + baseValue;
|
||||
const tempNoise = ((tempSeed % 256) / 256);
|
||||
const mixRatio = corruptionLevel * 0.7;
|
||||
|
||||
corruptedValue = Math.floor(corruptedValue * (1 - mixRatio) + tempNoise * 255 * mixRatio);
|
||||
}
|
||||
|
||||
const ringFreq = 0.3 + corruptionLevel * 0.5;
|
||||
const temporalRingPhase = temporalDrift * 0.01;
|
||||
const ringing = Math.sin(context.x * ringFreq + temporalRingPhase) * Math.cos(context.y * ringFreq + temporalRingPhase * 0.7) * corruptionLevel * 15;
|
||||
corruptedValue += ringing;
|
||||
|
||||
const edgeDetect = Math.abs(Math.sin(context.x * 0.2 + temporalDrift * 0.005)) + Math.abs(Math.sin(context.y * 0.2 + temporalDrift * 0.007));
|
||||
if (edgeDetect > 1.5) {
|
||||
const mosquitoNoise = ((blockSeed >> 20) & 31) - 16 + Math.sin(temporalDrift * 0.1) * 3;
|
||||
corruptedValue += mosquitoNoise * corruptionLevel;
|
||||
}
|
||||
|
||||
return Math.floor(Math.max(0, Math.min(RGB.MAX_VALUE, corruptedValue)));
|
||||
};
|
||||
private foldMode = (context: PixelContext, precomputed: PrecomputedContext): number => {
|
||||
const baseValue = Math.abs(context.value) % 256;
|
||||
const normalizedValue = baseValue / 255.0;
|
||||
|
||||
const foldLines = [
|
||||
{
|
||||
position: 0.2 + Math.sin(context.x * 0.01 + normalizedValue * 4) * 0.15,
|
||||
angle: Math.PI * 0.25 + normalizedValue * Math.PI * 0.5,
|
||||
strength: 1.0,
|
||||
type: 'valley'
|
||||
},
|
||||
{
|
||||
position: 0.5 + Math.cos(context.y * 0.008 + normalizedValue * 3) * 0.2,
|
||||
angle: Math.PI * 0.75 + Math.sin(normalizedValue * 6) * Math.PI * 0.3,
|
||||
strength: 0.8,
|
||||
type: 'mountain'
|
||||
},
|
||||
{
|
||||
position: 0.75 + Math.sin((context.x + context.y) * 0.005 + normalizedValue * 2) * 0.1,
|
||||
angle: Math.PI * 1.1 + Math.cos(normalizedValue * 8) * Math.PI * 0.4,
|
||||
strength: 0.6,
|
||||
type: 'valley'
|
||||
},
|
||||
{
|
||||
position: 0.35 + Math.cos(context.x * 0.012 - context.y * 0.008 + normalizedValue * 5) * 0.18,
|
||||
angle: Math.PI * 1.5 + normalizedValue * Math.PI,
|
||||
strength: 0.9,
|
||||
type: 'mountain'
|
||||
}
|
||||
];
|
||||
|
||||
let foldedValue = normalizedValue;
|
||||
let geometryComplexity = 1.0;
|
||||
|
||||
for (const fold of foldLines) {
|
||||
const cos_a = Math.cos(fold.angle);
|
||||
const sin_a = Math.sin(fold.angle);
|
||||
|
||||
const rotX = (context.x - precomputed.centerX) * cos_a + (context.y - precomputed.centerY) * sin_a;
|
||||
const rotY = -(context.x - precomputed.centerX) * sin_a + (context.y - precomputed.centerY) * cos_a;
|
||||
|
||||
const foldDistance = Math.abs(rotY) / context.height;
|
||||
const foldPosition = (rotX / context.width + 1) * 0.5;
|
||||
|
||||
const foldSide = Math.sign(rotY);
|
||||
|
||||
if (Math.abs(foldedValue - fold.position) < 0.3) {
|
||||
const foldInfluence = Math.exp(-foldDistance * 8) * fold.strength;
|
||||
|
||||
if (fold.type === 'valley') {
|
||||
if (foldedValue > fold.position) {
|
||||
const excess = foldedValue - fold.position;
|
||||
const foldedExcess = excess * (1 - foldInfluence) - excess * foldInfluence * 0.5;
|
||||
foldedValue = fold.position + foldedExcess;
|
||||
} else {
|
||||
const deficit = fold.position - foldedValue;
|
||||
const foldedDeficit = deficit * (1 - foldInfluence) - deficit * foldInfluence * 0.5;
|
||||
foldedValue = fold.position - foldedDeficit;
|
||||
}
|
||||
} else {
|
||||
if (foldedValue > fold.position) {
|
||||
const excess = foldedValue - fold.position;
|
||||
const expandedExcess = excess * (1 + foldInfluence * 0.8);
|
||||
foldedValue = fold.position + expandedExcess;
|
||||
} else {
|
||||
const deficit = fold.position - foldedValue;
|
||||
const expandedDeficit = deficit * (1 + foldInfluence * 0.8);
|
||||
foldedValue = fold.position - expandedDeficit;
|
||||
}
|
||||
}
|
||||
|
||||
const creaseSharpness = Math.exp(-Math.abs(foldedValue - fold.position) * 20) * fold.strength;
|
||||
const creaseEffect = Math.sin(foldPosition * Math.PI * 8 + fold.angle) * creaseSharpness * 0.1;
|
||||
foldedValue += creaseEffect;
|
||||
|
||||
geometryComplexity *= (1 + foldInfluence * 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
const recursiveFolds = 3;
|
||||
for (let r = 0; r < recursiveFolds; r++) {
|
||||
const recursiveScale = Math.pow(0.6, r);
|
||||
const recursiveFreq = Math.pow(2, r + 2);
|
||||
|
||||
const recursiveFoldPos = 0.5 + Math.sin(foldedValue * Math.PI * recursiveFreq + r) * 0.2 * recursiveScale;
|
||||
|
||||
if (Math.abs(foldedValue - recursiveFoldPos) < 0.1 * recursiveScale) {
|
||||
const recursiveInfluence = Math.exp(-Math.abs(foldedValue - recursiveFoldPos) * 30 / recursiveScale) * recursiveScale;
|
||||
|
||||
const microFold = (foldedValue - recursiveFoldPos) * (1 - recursiveInfluence * 0.7);
|
||||
foldedValue = recursiveFoldPos + microFold;
|
||||
|
||||
const microCrease = Math.sin(foldedValue * Math.PI * recursiveFreq * 4) * recursiveInfluence * 0.05;
|
||||
foldedValue += microCrease;
|
||||
}
|
||||
}
|
||||
|
||||
const distortion = Math.sin(foldedValue * Math.PI * geometryComplexity) * Math.cos(geometryComplexity * 2) * 0.15;
|
||||
foldedValue += distortion;
|
||||
|
||||
const edgeDetection = Math.abs(Math.sin(foldedValue * Math.PI * 16)) * 0.2;
|
||||
const edgeEnhancement = Math.pow(edgeDetection, 2) * geometryComplexity * 0.1;
|
||||
foldedValue += edgeEnhancement;
|
||||
|
||||
const materialResponse = Math.tanh(foldedValue * 3) * 0.85 + 0.15;
|
||||
const paperTexture = Math.sin(materialResponse * Math.PI * 32 + geometryComplexity) * 0.05;
|
||||
|
||||
const finalValue = (materialResponse + paperTexture) * RGB.MAX_VALUE;
|
||||
|
||||
return Math.floor(Math.max(0, Math.min(RGB.MAX_VALUE, finalValue)));
|
||||
};
|
||||
}
|
||||
|
||||
class WaveConstants {
|
||||
static readonly SOURCES = [
|
||||
{ x: 0.3, y: 0.3 },
|
||||
{ x: 0.7, y: 0.3 },
|
||||
{ x: 0.5, y: 0.7 },
|
||||
{ x: 0.2, y: 0.8 },
|
||||
];
|
||||
}
|
||||
112
src/shader/types/ShaderContext.ts
Normal file
112
src/shader/types/ShaderContext.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Context object passed to shader functions containing all necessary variables
|
||||
* and state for shader execution. This replaces the previous 57+ parameter approach.
|
||||
*/
|
||||
export interface ShaderContext {
|
||||
// Core coordinates and indices
|
||||
x: number;
|
||||
y: number;
|
||||
t: number;
|
||||
i: number; // pixelIndex
|
||||
|
||||
// Geometric properties
|
||||
r: number; // radius
|
||||
a: number; // angle
|
||||
u: number; // normalized x (0-1)
|
||||
v: number; // normalized y (0-1)
|
||||
c: number; // normalizedDistance
|
||||
d: number; // manhattanDistance
|
||||
|
||||
// Canvas properties
|
||||
w: number; // canvasWidth
|
||||
h: number; // canvasHeight
|
||||
|
||||
// Time-based properties
|
||||
f: number; // frameCount
|
||||
p: number; // phase
|
||||
|
||||
// Noise and effects
|
||||
n: number; // noise
|
||||
z: number; // pseudoZ
|
||||
j: number; // jitter
|
||||
o: number; // oscillation
|
||||
g: number; // goldenRatio
|
||||
|
||||
// Feedback system
|
||||
b: number; // feedbackValue
|
||||
m: number; // momentum
|
||||
l: number; // laplacian
|
||||
k: number; // curvature
|
||||
s: number; // stateValue
|
||||
e: number; // echoValue
|
||||
|
||||
// Neighbor feedback
|
||||
bn: number; // north neighbor
|
||||
bs: number; // south neighbor
|
||||
be: number; // east neighbor
|
||||
bw: number; // west neighbor
|
||||
|
||||
// Input devices
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
mousePressed: number;
|
||||
mouseVX: number;
|
||||
mouseVY: number;
|
||||
mouseClickTime: number;
|
||||
|
||||
// Touch input
|
||||
touchCount: number;
|
||||
touch0X: number;
|
||||
touch0Y: number;
|
||||
touch1X: number;
|
||||
touch1Y: number;
|
||||
pinchScale: number;
|
||||
pinchRotation: number;
|
||||
|
||||
// Device motion
|
||||
accelX: number;
|
||||
accelY: number;
|
||||
accelZ: number;
|
||||
gyroX: number;
|
||||
gyroY: number;
|
||||
gyroZ: number;
|
||||
|
||||
// Audio
|
||||
audioLevel: number;
|
||||
bassLevel: number;
|
||||
midLevel: number;
|
||||
trebleLevel: number;
|
||||
bpm: number;
|
||||
|
||||
// Time function
|
||||
_t: (mod: number) => number;
|
||||
|
||||
// Block coordinates
|
||||
bx: number; // block x
|
||||
by: number; // block y
|
||||
sx: number; // signed x
|
||||
sy: number; // signed y
|
||||
qx: number; // quarter block x
|
||||
qy: number; // quarter block y
|
||||
}
|
||||
|
||||
/**
|
||||
* Type definition for compiled shader functions
|
||||
*/
|
||||
export type ShaderFunction = (ctx: ShaderContext) => number;
|
||||
|
||||
/**
|
||||
* Creates a default shader context with zero values
|
||||
*/
|
||||
export function createDefaultShaderContext(): ShaderContext {
|
||||
return {
|
||||
x: 0, y: 0, t: 0, i: 0, r: 0, a: 0, u: 0, v: 0, c: 0, f: 0, d: 0, n: 0, b: 0,
|
||||
bn: 0, bs: 0, be: 0, bw: 0, w: 0, h: 0, p: 0, z: 0, j: 0, o: 0, g: 0, m: 0,
|
||||
l: 0, k: 0, s: 0, e: 0, mouseX: 0, mouseY: 0, mousePressed: 0, mouseVX: 0,
|
||||
mouseVY: 0, mouseClickTime: 0, touchCount: 0, touch0X: 0, touch0Y: 0, touch1X: 0,
|
||||
touch1Y: 0, pinchScale: 1, pinchRotation: 0, accelX: 0, accelY: 0, accelZ: 0,
|
||||
gyroX: 0, gyroY: 0, gyroZ: 0, audioLevel: 0, bassLevel: 0, midLevel: 0,
|
||||
trebleLevel: 0, bpm: 120, _t: (_mod: number) => 0, bx: 0, by: 0, sx: 0, sy: 0,
|
||||
qx: 0, qy: 0
|
||||
};
|
||||
}
|
||||
56
src/shader/types/WorkerMessage.ts
Normal file
56
src/shader/types/WorkerMessage.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Message types for communication between main thread and shader workers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Message sent from main thread to worker
|
||||
*/
|
||||
export interface WorkerMessage {
|
||||
id: string;
|
||||
type: 'compile' | 'render';
|
||||
code?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
time?: number;
|
||||
renderMode?: string;
|
||||
valueMode?: string; // 'integer' or 'float'
|
||||
hueShift?: number; // Hue shift in degrees (0-360)
|
||||
startY?: number; // Y offset for tile rendering
|
||||
fullWidth?: number; // Full canvas width for center calculations
|
||||
fullHeight?: number; // Full canvas height for center calculations
|
||||
mouseX?: number;
|
||||
mouseY?: number;
|
||||
mousePressed?: boolean;
|
||||
mouseVX?: number;
|
||||
mouseVY?: number;
|
||||
mouseClickTime?: number;
|
||||
touchCount?: number;
|
||||
touch0X?: number;
|
||||
touch0Y?: number;
|
||||
touch1X?: number;
|
||||
touch1Y?: number;
|
||||
pinchScale?: number;
|
||||
pinchRotation?: number;
|
||||
accelX?: number;
|
||||
accelY?: number;
|
||||
accelZ?: number;
|
||||
gyroX?: number;
|
||||
gyroY?: number;
|
||||
gyroZ?: number;
|
||||
audioLevel?: number;
|
||||
bassLevel?: number;
|
||||
midLevel?: number;
|
||||
trebleLevel?: number;
|
||||
bpm?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response message sent from worker to main thread
|
||||
*/
|
||||
export interface WorkerResponse {
|
||||
id: string;
|
||||
type: 'compiled' | 'rendered' | 'error';
|
||||
success: boolean;
|
||||
imageData?: ImageData;
|
||||
error?: string;
|
||||
}
|
||||
7
src/shader/types/index.ts
Normal file
7
src/shader/types/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Type definitions for shader system
|
||||
*/
|
||||
|
||||
export type { ShaderContext, ShaderFunction } from './ShaderContext';
|
||||
export { createDefaultShaderContext } from './ShaderContext';
|
||||
export type { WorkerMessage, WorkerResponse } from './WorkerMessage';
|
||||
317
src/shader/worker/ShaderWorker.ts
Normal file
317
src/shader/worker/ShaderWorker.ts
Normal file
@ -0,0 +1,317 @@
|
||||
import { WorkerMessage, WorkerResponse, createDefaultShaderContext } from '../types';
|
||||
import { ShaderCompiler } from '../core/ShaderCompiler';
|
||||
import { ShaderCache } from '../core/ShaderCache';
|
||||
import { FeedbackSystem } from '../rendering/FeedbackSystem';
|
||||
import { PixelRenderer } from '../rendering/PixelRenderer';
|
||||
import { PERFORMANCE } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Main shader worker class - handles compilation and rendering
|
||||
*/
|
||||
class ShaderWorker {
|
||||
private compiledFunction: any = null;
|
||||
private lastCode: string = '';
|
||||
private cache: ShaderCache;
|
||||
private feedbackSystem: FeedbackSystem;
|
||||
private pixelRenderer: PixelRenderer;
|
||||
private shaderContext = createDefaultShaderContext();
|
||||
private lastFrameTime: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.cache = new ShaderCache();
|
||||
this.feedbackSystem = new FeedbackSystem();
|
||||
this.pixelRenderer = new PixelRenderer(this.feedbackSystem, this.shaderContext);
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
|
||||
this.handleMessage(e.data);
|
||||
};
|
||||
}
|
||||
|
||||
private handleMessage(message: WorkerMessage): void {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'compile':
|
||||
this.compileShader(message.id, message.code!);
|
||||
break;
|
||||
case 'render':
|
||||
this.renderShader(
|
||||
message.id,
|
||||
message.width!,
|
||||
message.height!,
|
||||
message.time!,
|
||||
message.renderMode || 'classic',
|
||||
message.valueMode || 'integer',
|
||||
message,
|
||||
message.startY || 0
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
this.postError(
|
||||
message.id,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private compileShader(id: string, code: string): void {
|
||||
const codeHash = ShaderCompiler.hashCode(code);
|
||||
|
||||
if (code === this.lastCode && this.compiledFunction) {
|
||||
this.postMessage({ id, type: 'compiled', success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check compilation cache
|
||||
const cachedFunction = this.cache.getCompiledShader(codeHash);
|
||||
if (cachedFunction) {
|
||||
this.compiledFunction = cachedFunction;
|
||||
this.lastCode = code;
|
||||
this.postMessage({ id, type: 'compiled', success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.compiledFunction = ShaderCompiler.compile(code);
|
||||
|
||||
// Cache the compiled function
|
||||
if (this.compiledFunction) {
|
||||
this.cache.setCompiledShader(codeHash, this.compiledFunction);
|
||||
}
|
||||
|
||||
this.lastCode = code;
|
||||
this.postMessage({ id, type: 'compiled', success: true });
|
||||
} catch (error) {
|
||||
this.compiledFunction = null;
|
||||
this.postError(
|
||||
id,
|
||||
error instanceof Error ? error.message : 'Compilation failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderShader(
|
||||
id: string,
|
||||
width: number,
|
||||
height: number,
|
||||
time: number,
|
||||
renderMode: string,
|
||||
valueMode: string,
|
||||
message: WorkerMessage,
|
||||
startY: number = 0
|
||||
): void {
|
||||
if (!this.compiledFunction) {
|
||||
this.postError(id, 'No compiled shader');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = this.cache.getOrCreateImageData(width, height);
|
||||
const data = imageData.data;
|
||||
const startTime = performance.now();
|
||||
const maxRenderTime = PERFORMANCE.MAX_RENDER_TIME_MS;
|
||||
|
||||
// Initialize feedback buffers if needed
|
||||
this.feedbackSystem.initializeBuffers(width, height);
|
||||
|
||||
// Update frame timing for frame rate independence
|
||||
const deltaTime = time - this.lastFrameTime;
|
||||
this.lastFrameTime = time;
|
||||
|
||||
try {
|
||||
// Use tiled rendering for better timeout handling
|
||||
this.renderTiled(
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
time,
|
||||
renderMode,
|
||||
valueMode,
|
||||
message,
|
||||
startTime,
|
||||
maxRenderTime,
|
||||
startY,
|
||||
deltaTime
|
||||
);
|
||||
|
||||
// Finalize frame processing
|
||||
this.feedbackSystem.finalizeFrame();
|
||||
|
||||
this.postMessage({ id, type: 'rendered', success: true, imageData });
|
||||
} catch (error) {
|
||||
this.postError(
|
||||
id,
|
||||
error instanceof Error ? error.message : 'Render failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderTiled(
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
time: number,
|
||||
renderMode: string,
|
||||
valueMode: string,
|
||||
message: WorkerMessage,
|
||||
startTime: number,
|
||||
maxRenderTime: number,
|
||||
yOffset: number = 0,
|
||||
deltaTime: number = 0.016
|
||||
): void {
|
||||
const tileSize = PERFORMANCE.DEFAULT_TILE_SIZE;
|
||||
const tilesX = Math.ceil(width / tileSize);
|
||||
const tilesY = Math.ceil(height / tileSize);
|
||||
|
||||
// Pre-calculate constants outside the loop for performance
|
||||
const fullWidth = message.fullWidth || width;
|
||||
const fullHeight = message.fullHeight || message.height! + yOffset;
|
||||
const centerX = fullWidth / 2;
|
||||
const centerY = fullHeight / 2;
|
||||
const maxDistance = Math.sqrt(centerX ** 2 + centerY ** 2);
|
||||
const invMaxDistance = 1 / maxDistance;
|
||||
const invFullWidth = 1 / fullWidth;
|
||||
const invFullHeight = 1 / fullHeight;
|
||||
const frameCount = Math.floor(time * 60);
|
||||
const goldenRatio = 1.618033988749;
|
||||
const phase = (time * Math.PI * 2) % (Math.PI * 2);
|
||||
const timeTwoPi = time * 2 * Math.PI;
|
||||
const fullWidthHalf = fullWidth >> 1;
|
||||
const fullHeightHalf = fullHeight >> 1;
|
||||
|
||||
for (let tileY = 0; tileY < tilesY; tileY++) {
|
||||
for (let tileX = 0; tileX < tilesX; tileX++) {
|
||||
// Check timeout before each tile
|
||||
if (performance.now() - startTime > maxRenderTime) {
|
||||
const startX = tileX * tileSize;
|
||||
const startY = tileY * tileSize;
|
||||
this.fillRemainingPixels(data, width, height, startY, startX);
|
||||
return;
|
||||
}
|
||||
|
||||
const tileStartX = tileX * tileSize;
|
||||
const tileStartY = tileY * tileSize;
|
||||
const tileEndX = Math.min(tileStartX + tileSize, width);
|
||||
const tileEndY = Math.min(tileStartY + tileSize, height);
|
||||
|
||||
this.renderTile(
|
||||
data,
|
||||
width,
|
||||
tileStartX,
|
||||
tileStartY,
|
||||
tileEndX,
|
||||
tileEndY,
|
||||
time,
|
||||
renderMode,
|
||||
valueMode,
|
||||
message,
|
||||
yOffset,
|
||||
deltaTime,
|
||||
// Pre-calculated constants
|
||||
centerX,
|
||||
centerY,
|
||||
maxDistance,
|
||||
invMaxDistance,
|
||||
invFullWidth,
|
||||
invFullHeight,
|
||||
frameCount,
|
||||
goldenRatio,
|
||||
phase,
|
||||
timeTwoPi,
|
||||
fullWidthHalf,
|
||||
fullHeightHalf
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderTile(
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
time: number,
|
||||
renderMode: string,
|
||||
valueMode: string,
|
||||
message: WorkerMessage,
|
||||
yOffset: number,
|
||||
deltaTime: number,
|
||||
// Pre-calculated constants
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
maxDistance: number,
|
||||
invMaxDistance: number,
|
||||
invFullWidth: number,
|
||||
invFullHeight: number,
|
||||
frameCount: number,
|
||||
goldenRatio: number,
|
||||
phase: number,
|
||||
timeTwoPi: number,
|
||||
fullWidthHalf: number,
|
||||
fullHeightHalf: number
|
||||
): void {
|
||||
for (let y = startY; y < endY; y++) {
|
||||
for (let x = startX; x < endX; x++) {
|
||||
const actualY = y + yOffset;
|
||||
|
||||
this.pixelRenderer.renderPixel(
|
||||
data,
|
||||
x,
|
||||
y,
|
||||
actualY,
|
||||
width,
|
||||
time,
|
||||
renderMode,
|
||||
valueMode,
|
||||
message,
|
||||
this.compiledFunction,
|
||||
// Pre-calculated constants
|
||||
centerX,
|
||||
centerY,
|
||||
maxDistance,
|
||||
invMaxDistance,
|
||||
invFullWidth,
|
||||
invFullHeight,
|
||||
frameCount,
|
||||
goldenRatio,
|
||||
phase,
|
||||
timeTwoPi,
|
||||
fullWidthHalf,
|
||||
fullHeightHalf,
|
||||
deltaTime
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fillRemainingPixels(
|
||||
data: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
startY: number,
|
||||
startX: number
|
||||
): void {
|
||||
for (let remainingY = startY; remainingY < height; remainingY++) {
|
||||
const xStart = remainingY === startY ? startX : 0;
|
||||
for (let remainingX = xStart; remainingX < width; remainingX++) {
|
||||
const i = (remainingY * width + remainingX) * 4;
|
||||
data[i] = 0;
|
||||
data[i + 1] = 0;
|
||||
data[i + 2] = 0;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private postMessage(response: WorkerResponse): void {
|
||||
self.postMessage(response);
|
||||
}
|
||||
|
||||
private postError(id: string, error: string): void {
|
||||
this.postMessage({ id, type: 'error', success: false, error });
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize worker
|
||||
new ShaderWorker();
|
||||
158
src/stores/appSettings.ts
Normal file
158
src/stores/appSettings.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { atom } from 'nanostores';
|
||||
import { DEFAULTS, VALUE_MODES, ValueMode, RENDER_MODES } from '../utils/constants';
|
||||
|
||||
export interface AppSettings {
|
||||
resolution: number;
|
||||
fps: number;
|
||||
renderMode: string;
|
||||
valueMode?: ValueMode;
|
||||
uiOpacity?: number;
|
||||
hueShift?: number;
|
||||
lastShaderCode?: string;
|
||||
timeSpeed?: number;
|
||||
currentBPM?: number;
|
||||
}
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
resolution: DEFAULTS.RESOLUTION,
|
||||
fps: DEFAULTS.FPS,
|
||||
renderMode: DEFAULTS.RENDER_MODE,
|
||||
valueMode: DEFAULTS.VALUE_MODE,
|
||||
uiOpacity: DEFAULTS.UI_OPACITY,
|
||||
hueShift: 0,
|
||||
lastShaderCode: DEFAULTS.SHADER_CODE,
|
||||
timeSpeed: DEFAULTS.TIME_SPEED,
|
||||
currentBPM: 120,
|
||||
};
|
||||
|
||||
export const $appSettings = atom<AppSettings>(defaultSettings);
|
||||
|
||||
export function updateAppSettings(settings: Partial<AppSettings>) {
|
||||
$appSettings.set({ ...$appSettings.get(), ...settings });
|
||||
}
|
||||
|
||||
export function cycleValueMode(direction: 'forward' | 'backward' = 'forward') {
|
||||
const currentSettings = $appSettings.get();
|
||||
const currentMode = currentSettings.valueMode || DEFAULTS.VALUE_MODE;
|
||||
const currentIndex = VALUE_MODES.indexOf(currentMode);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// Fall back to first mode if current mode not found
|
||||
updateAppSettings({ valueMode: VALUE_MODES[0] });
|
||||
return VALUE_MODES[0];
|
||||
}
|
||||
|
||||
let nextIndex: number;
|
||||
if (direction === 'forward') {
|
||||
nextIndex = (currentIndex + 1) % VALUE_MODES.length;
|
||||
} else {
|
||||
nextIndex = (currentIndex - 1 + VALUE_MODES.length) % VALUE_MODES.length;
|
||||
}
|
||||
|
||||
const nextMode = VALUE_MODES[nextIndex];
|
||||
|
||||
updateAppSettings({ valueMode: nextMode });
|
||||
|
||||
// Return the new mode for UI feedback
|
||||
return nextMode;
|
||||
}
|
||||
|
||||
export function cycleRenderMode(direction: 'forward' | 'backward' = 'forward') {
|
||||
const currentSettings = $appSettings.get();
|
||||
const currentMode = currentSettings.renderMode || DEFAULTS.RENDER_MODE;
|
||||
const currentIndex = RENDER_MODES.indexOf(currentMode as any);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// Fall back to first mode if current mode not found
|
||||
updateAppSettings({ renderMode: RENDER_MODES[0] });
|
||||
return RENDER_MODES[0];
|
||||
}
|
||||
|
||||
let nextIndex: number;
|
||||
if (direction === 'forward') {
|
||||
nextIndex = (currentIndex + 1) % RENDER_MODES.length;
|
||||
} else {
|
||||
nextIndex = (currentIndex - 1 + RENDER_MODES.length) % RENDER_MODES.length;
|
||||
}
|
||||
|
||||
const nextMode = RENDER_MODES[nextIndex];
|
||||
|
||||
updateAppSettings({ renderMode: nextMode });
|
||||
|
||||
// Return the new mode for UI feedback
|
||||
return nextMode;
|
||||
}
|
||||
|
||||
// Tap tempo state and functions
|
||||
interface TapTempoState {
|
||||
taps: number[];
|
||||
maxTaps: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
const tapTempoState: TapTempoState = {
|
||||
taps: [],
|
||||
maxTaps: 4,
|
||||
timeoutMs: 2500,
|
||||
};
|
||||
|
||||
export function handleTapTempo(): void {
|
||||
const now = Date.now();
|
||||
const state = tapTempoState;
|
||||
|
||||
// Clear old taps that are outside the timeout window
|
||||
state.taps = state.taps.filter(tap => now - tap < state.timeoutMs);
|
||||
|
||||
// Add new tap
|
||||
state.taps.push(now);
|
||||
|
||||
// Keep only the most recent taps
|
||||
if (state.taps.length > state.maxTaps) {
|
||||
state.taps.shift();
|
||||
}
|
||||
|
||||
// Calculate BPM and update time speed if we have enough taps
|
||||
if (state.taps.length >= 2) {
|
||||
const intervals = [];
|
||||
for (let i = 1; i < state.taps.length; i++) {
|
||||
intervals.push(state.taps[i] - state.taps[i - 1]);
|
||||
}
|
||||
|
||||
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||
const bpm = 60000 / avgInterval;
|
||||
|
||||
// Map BPM to time speed (120 BPM = 1.0x speed)
|
||||
const targetBPM = 120;
|
||||
const timeSpeed = Math.max(0.1, Math.min(5.0, bpm / targetBPM));
|
||||
|
||||
updateAppSettings({ timeSpeed, currentBPM: bpm });
|
||||
|
||||
console.log(`Tap tempo: ${bpm.toFixed(1)} BPM, ${timeSpeed.toFixed(2)}x speed`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetTapTempo(): void {
|
||||
tapTempoState.taps = [];
|
||||
updateAppSettings({ timeSpeed: 1.0, currentBPM: 120 });
|
||||
}
|
||||
|
||||
export function randomizeVisualSettings(): void {
|
||||
// Random hue shift (0-359 degrees)
|
||||
const randomHue = Math.floor(Math.random() * 360);
|
||||
|
||||
// Random value mode
|
||||
const randomValueIndex = Math.floor(Math.random() * VALUE_MODES.length);
|
||||
const randomValueMode = VALUE_MODES[randomValueIndex];
|
||||
|
||||
// Random render mode
|
||||
const randomRenderIndex = Math.floor(Math.random() * RENDER_MODES.length);
|
||||
const randomRenderMode = RENDER_MODES[randomRenderIndex];
|
||||
|
||||
updateAppSettings({
|
||||
hueShift: randomHue,
|
||||
valueMode: randomValueMode,
|
||||
renderMode: randomRenderMode,
|
||||
});
|
||||
|
||||
console.log(`Randomized visuals: Hue ${randomHue}°, Value mode: ${randomValueMode}, Render mode: ${randomRenderMode}`);
|
||||
}
|
||||
176
src/stores/input.ts
Normal file
176
src/stores/input.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export interface InputState {
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
mousePressed: boolean;
|
||||
mouseVX: number;
|
||||
mouseVY: number;
|
||||
mouseClickTime: number;
|
||||
touchCount: number;
|
||||
touch0X: number;
|
||||
touch0Y: number;
|
||||
touch1X: number;
|
||||
touch1Y: number;
|
||||
pinchScale: number;
|
||||
pinchRotation: number;
|
||||
accelX: number;
|
||||
accelY: number;
|
||||
accelZ: number;
|
||||
gyroX: number;
|
||||
gyroY: number;
|
||||
gyroZ: number;
|
||||
audioLevel: number;
|
||||
bassLevel: number;
|
||||
midLevel: number;
|
||||
trebleLevel: number;
|
||||
audioEnabled: boolean;
|
||||
webcamEnabled: boolean;
|
||||
}
|
||||
|
||||
export const defaultInputState: InputState = {
|
||||
mouseX: 0,
|
||||
mouseY: 0,
|
||||
mousePressed: false,
|
||||
mouseVX: 0,
|
||||
mouseVY: 0,
|
||||
mouseClickTime: 0,
|
||||
touchCount: 0,
|
||||
touch0X: 0,
|
||||
touch0Y: 0,
|
||||
touch1X: 0,
|
||||
touch1Y: 0,
|
||||
pinchScale: 1,
|
||||
pinchRotation: 0,
|
||||
accelX: 0,
|
||||
accelY: 0,
|
||||
accelZ: 0,
|
||||
gyroX: 0,
|
||||
gyroY: 0,
|
||||
gyroZ: 0,
|
||||
audioLevel: 0,
|
||||
bassLevel: 0,
|
||||
midLevel: 0,
|
||||
trebleLevel: 0,
|
||||
audioEnabled: false,
|
||||
webcamEnabled: false,
|
||||
};
|
||||
|
||||
export const $input = atom<InputState>(defaultInputState);
|
||||
|
||||
let mouseUpdatePending = false;
|
||||
let pendingMouseData = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressed: false,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
clickTime: 0,
|
||||
};
|
||||
|
||||
export function updateMousePosition(
|
||||
x: number,
|
||||
y: number,
|
||||
pressed: boolean,
|
||||
vx: number,
|
||||
vy: number,
|
||||
clickTime: number
|
||||
) {
|
||||
pendingMouseData = { x, y, pressed, vx, vy, clickTime };
|
||||
|
||||
if (!mouseUpdatePending) {
|
||||
mouseUpdatePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
const current = $input.get();
|
||||
$input.set({
|
||||
...current,
|
||||
mouseX: pendingMouseData.x,
|
||||
mouseY: pendingMouseData.y,
|
||||
mousePressed: pendingMouseData.pressed,
|
||||
mouseVX: pendingMouseData.vx,
|
||||
mouseVY: pendingMouseData.vy,
|
||||
mouseClickTime: pendingMouseData.clickTime,
|
||||
});
|
||||
mouseUpdatePending = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let touchUpdatePending = false;
|
||||
let pendingTouchData = {
|
||||
count: 0,
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
export function updateTouchPosition(
|
||||
count: number,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
scale: number,
|
||||
rotation: number
|
||||
) {
|
||||
pendingTouchData = { count, x0, y0, x1, y1, scale, rotation };
|
||||
|
||||
if (!touchUpdatePending) {
|
||||
touchUpdatePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
const current = $input.get();
|
||||
$input.set({
|
||||
...current,
|
||||
touchCount: pendingTouchData.count,
|
||||
touch0X: pendingTouchData.x0,
|
||||
touch0Y: pendingTouchData.y0,
|
||||
touch1X: pendingTouchData.x1,
|
||||
touch1Y: pendingTouchData.y1,
|
||||
pinchScale: pendingTouchData.scale,
|
||||
pinchRotation: pendingTouchData.rotation,
|
||||
});
|
||||
touchUpdatePending = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDeviceMotion(
|
||||
ax: number,
|
||||
ay: number,
|
||||
az: number,
|
||||
gx: number,
|
||||
gy: number,
|
||||
gz: number
|
||||
) {
|
||||
$input.set({
|
||||
...$input.get(),
|
||||
accelX: ax,
|
||||
accelY: ay,
|
||||
accelZ: az,
|
||||
gyroX: gx,
|
||||
gyroY: gy,
|
||||
gyroZ: gz,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAudioData(
|
||||
level: number,
|
||||
bass: number,
|
||||
mid: number,
|
||||
treble: number
|
||||
) {
|
||||
$input.set({
|
||||
...$input.get(),
|
||||
audioLevel: level,
|
||||
bassLevel: bass,
|
||||
midLevel: mid,
|
||||
trebleLevel: treble,
|
||||
});
|
||||
}
|
||||
|
||||
export function setAudioEnabled(enabled: boolean) {
|
||||
$input.set({ ...$input.get(), audioEnabled: enabled });
|
||||
}
|
||||
59
src/stores/library.ts
Normal file
59
src/stores/library.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { atom } from 'nanostores';
|
||||
import { Storage, SavedShader } from '../Storage';
|
||||
|
||||
export interface LibraryState {
|
||||
shaders: SavedShader[];
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export const defaultLibraryState: LibraryState = {
|
||||
shaders: [],
|
||||
searchTerm: '',
|
||||
};
|
||||
|
||||
export const $library = atom<LibraryState>(defaultLibraryState);
|
||||
|
||||
export function loadShaders() {
|
||||
const shaders = Storage.getShaders();
|
||||
$library.set({ ...$library.get(), shaders });
|
||||
}
|
||||
|
||||
export function saveShader(name: string, code: string, settings?: any) {
|
||||
const shader = Storage.saveShader(name, code, settings);
|
||||
loadShaders(); // Reload to get updated list
|
||||
return shader;
|
||||
}
|
||||
|
||||
export function deleteShader(id: string) {
|
||||
Storage.deleteShader(id);
|
||||
loadShaders();
|
||||
}
|
||||
|
||||
export function renameShader(id: string, newName: string) {
|
||||
Storage.renameShader(id, newName);
|
||||
loadShaders();
|
||||
}
|
||||
|
||||
export function updateShaderUsage(id: string) {
|
||||
Storage.updateShaderUsage(id);
|
||||
loadShaders();
|
||||
}
|
||||
|
||||
export function setSearchTerm(term: string) {
|
||||
$library.set({ ...$library.get(), searchTerm: term });
|
||||
}
|
||||
|
||||
export function getFilteredShaders(): SavedShader[] {
|
||||
const { shaders, searchTerm } = $library.get();
|
||||
|
||||
if (!searchTerm.trim()) {
|
||||
return shaders;
|
||||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return shaders.filter(
|
||||
(shader) =>
|
||||
shader.name.toLowerCase().includes(term) ||
|
||||
shader.code.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
33
src/stores/shader.ts
Normal file
33
src/stores/shader.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export interface ShaderState {
|
||||
code: string;
|
||||
isCompiled: boolean;
|
||||
isAnimating: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const defaultShaderState: ShaderState = {
|
||||
code: 'x^y',
|
||||
isCompiled: false,
|
||||
isAnimating: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const $shader = atom<ShaderState>(defaultShaderState);
|
||||
|
||||
export function setShaderCode(code: string) {
|
||||
$shader.set({ ...$shader.get(), code, isCompiled: false, error: null });
|
||||
}
|
||||
|
||||
export function setShaderCompiled(isCompiled: boolean, error?: string) {
|
||||
$shader.set({
|
||||
...$shader.get(),
|
||||
isCompiled,
|
||||
error: error || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function setShaderAnimating(isAnimating: boolean) {
|
||||
$shader.set({ ...$shader.get(), isAnimating });
|
||||
}
|
||||
70
src/stores/ui.ts
Normal file
70
src/stores/ui.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export interface UIState {
|
||||
mobileMenuOpen: boolean;
|
||||
helpPopupOpen: boolean;
|
||||
shaderLibraryOpen: boolean;
|
||||
uiVisible: boolean;
|
||||
performanceWarningVisible: boolean;
|
||||
welcomePopupOpen: boolean;
|
||||
}
|
||||
|
||||
export const defaultUIState: UIState = {
|
||||
mobileMenuOpen: false,
|
||||
helpPopupOpen: false,
|
||||
shaderLibraryOpen: false,
|
||||
uiVisible: true,
|
||||
performanceWarningVisible: false,
|
||||
welcomePopupOpen: true,
|
||||
};
|
||||
|
||||
export const uiState = atom<UIState>(defaultUIState);
|
||||
|
||||
export function toggleMobileMenu() {
|
||||
uiState.set({
|
||||
...uiState.get(),
|
||||
mobileMenuOpen: !uiState.get().mobileMenuOpen,
|
||||
});
|
||||
}
|
||||
|
||||
export function closeMobileMenu() {
|
||||
uiState.set({ ...uiState.get(), mobileMenuOpen: false });
|
||||
}
|
||||
|
||||
export function showHelp() {
|
||||
uiState.set({ ...uiState.get(), helpPopupOpen: true });
|
||||
}
|
||||
|
||||
export function hideHelp() {
|
||||
uiState.set({ ...uiState.get(), helpPopupOpen: false });
|
||||
}
|
||||
|
||||
export function toggleShaderLibrary() {
|
||||
uiState.set({
|
||||
...uiState.get(),
|
||||
shaderLibraryOpen: !uiState.get().shaderLibraryOpen,
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleUI() {
|
||||
uiState.set({ ...uiState.get(), uiVisible: !uiState.get().uiVisible });
|
||||
}
|
||||
|
||||
export function showUI() {
|
||||
uiState.set({ ...uiState.get(), uiVisible: true });
|
||||
}
|
||||
|
||||
export function showPerformanceWarning() {
|
||||
uiState.set({ ...uiState.get(), performanceWarningVisible: true });
|
||||
setTimeout(() => {
|
||||
uiState.set({ ...uiState.get(), performanceWarningVisible: false });
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function showWelcome() {
|
||||
uiState.set({ ...uiState.get(), welcomePopupOpen: true });
|
||||
}
|
||||
|
||||
export function hideWelcome() {
|
||||
uiState.set({ ...uiState.get(), welcomePopupOpen: false });
|
||||
}
|
||||
866
src/styles/main.css
Normal file
866
src/styles/main.css
Normal file
@ -0,0 +1,866 @@
|
||||
:root {
|
||||
--ui-opacity: 0.3;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation; /* Allow pan and zoom but disable double-tap zoom */
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ff9500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #ffb143;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #ff9500;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
touch-action: none; /* Disable all touch gestures on canvas for shader interaction */
|
||||
pointer-events: auto; /* Allow canvas interactions */
|
||||
}
|
||||
|
||||
#topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
z-index: 100;
|
||||
pointer-events: auto; /* Ensure topbar can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#topbar .title {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
#topbar .controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#topbar .controls-desktop {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#topbar .controls-mobile {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#hamburger-menu {
|
||||
display: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#hamburger-menu:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
#hamburger-menu svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
#mobile-menu {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
right: -320px;
|
||||
width: 320px;
|
||||
max-width: 80vw;
|
||||
height: calc(100vh - 56px);
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
backdrop-filter: blur(3px);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 150;
|
||||
transition: right 0.3s ease;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
pointer-events: auto; /* Ensure mobile menu can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#mobile-menu.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#mobile-menu h3 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mobile-menu-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mobile-menu-item {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.mobile-menu-item label {
|
||||
display: block;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.mobile-menu-item select,
|
||||
.mobile-menu-item input[type='range'] {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mobile-menu-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-menu-buttons button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-menu-buttons button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
#mobile-menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 149;
|
||||
pointer-events: none; /* Don't block clicks when hidden */
|
||||
}
|
||||
|
||||
#mobile-menu-overlay.open {
|
||||
display: block;
|
||||
pointer-events: auto; /* Only block clicks when visible */
|
||||
}
|
||||
|
||||
#topbar button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#topbar button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Lucide icon styles */
|
||||
[data-lucide] {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
button svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Ensure all button contents don't intercept clicks */
|
||||
button *,
|
||||
button svg,
|
||||
button [data-lucide] {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
#editor-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: auto; /* Ensure editor panel can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#editor-panel.minimal {
|
||||
height: 50px;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
backdrop-filter: blur(2px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 16px;
|
||||
padding: 15px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
touch-action: manipulation; /* Allow normal touch interactions for text editing */
|
||||
}
|
||||
|
||||
#eval-btn {
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
backdrop-filter: blur(2px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
padding: 20px 30px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
#eval-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
#eval-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
#editor.minimal {
|
||||
padding: 12px 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#eval-btn.minimal {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#help-popup {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
z-index: 1000;
|
||||
max-width: 90vw;
|
||||
width: 800px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
pointer-events: auto; /* Ensure help popup can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#help-popup h3 {
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#help-popup .help-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#help-popup .help-section h4 {
|
||||
color: #ccc;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
#help-popup .help-section p {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#help-popup .close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 15px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Welcome Popup */
|
||||
.welcome-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.welcome-popup-content {
|
||||
position: relative;
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
margin-bottom: 30px;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.welcome-content p {
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.welcome-content h3 {
|
||||
color: #fff;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.welcome-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.welcome-content li {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.welcome-content li:before {
|
||||
content: '▸';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.welcome-content strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.help-hint {
|
||||
margin-top: 30px;
|
||||
padding: 10px 15px;
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.help-hint kbd {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.dismiss-hint {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#show-ui-btn {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#show-ui-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
#shader-library {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: -300px;
|
||||
width: 300px;
|
||||
height: calc(100vh - 56px);
|
||||
background: rgba(0, 0, 0, calc(var(--ui-opacity) + 0.1));
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 90;
|
||||
transition: left 0.3s ease;
|
||||
backdrop-filter: blur(3px);
|
||||
overflow-y: auto;
|
||||
pointer-events: auto; /* Ensure shader library can be clicked */
|
||||
touch-action: manipulation; /* Allow normal touch interactions */
|
||||
}
|
||||
|
||||
#shader-library-trigger {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
width: 20px;
|
||||
height: calc(100vh - 56px);
|
||||
z-index: 91;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#shader-library-trigger:hover + #shader-library,
|
||||
#shader-library:hover {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#shader-library.open {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.library-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.library-header h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.save-shader {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-shader {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search-shader input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-shader input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.save-shader input {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-shader button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-shader button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.shader-list {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
|
||||
.shader-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shader-item-header {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shader-item-header:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.shader-name {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.shader-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shader-action {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.shader-action:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.shader-action.rename {
|
||||
background: rgba(52, 152, 219, 0.3);
|
||||
border-color: rgba(52, 152, 219, 0.5);
|
||||
}
|
||||
|
||||
.shader-action.rename:hover {
|
||||
background: rgba(52, 152, 219, 0.5);
|
||||
}
|
||||
|
||||
.shader-action.delete {
|
||||
background: rgba(231, 76, 60, 0.3);
|
||||
border-color: rgba(231, 76, 60, 0.5);
|
||||
}
|
||||
|
||||
.shader-action.delete:hover {
|
||||
background: rgba(231, 76, 60, 0.5);
|
||||
}
|
||||
|
||||
.shader-code {
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||
color: #ccc;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#performance-warning {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 20px;
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
#topbar .controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#topbar .controls-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#topbar .controls-mobile {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#hamburger-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
height: 56px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#topbar .title {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#topbar .controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
#topbar button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#topbar label {
|
||||
font-size: 11px !important;
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
#topbar select {
|
||||
padding: 2px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
#help-popup {
|
||||
width: 95vw;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#editor-panel {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#shader-library {
|
||||
width: 100%;
|
||||
left: -100%;
|
||||
top: 56px;
|
||||
height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
#shader-library-trigger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#topbar {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#topbar .title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#topbar button {
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#topbar label {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
#topbar select {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
#help-popup {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
#help-popup h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#help-popup .help-section h4 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#help-popup .help-section p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.welcome-popup-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.welcome-content h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#editor-panel {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.help-content {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Share button tooltip animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
72
src/utils/LRUCache.ts
Normal file
72
src/utils/LRUCache.ts
Normal file
@ -0,0 +1,72 @@
|
||||
export class LRUCache<K, V> {
|
||||
private maxSize: number;
|
||||
private cache: Map<K, V>;
|
||||
private accessOrder: K[];
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.maxSize = maxSize;
|
||||
this.cache = new Map();
|
||||
this.accessOrder = [];
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value !== undefined) {
|
||||
this.markAsUsed(key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.set(key, value);
|
||||
this.markAsUsed(key);
|
||||
} else {
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
this.evictLeastUsed();
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
this.accessOrder.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
delete(key: K): boolean {
|
||||
if (this.cache.delete(key)) {
|
||||
this.removeFromAccessOrder(key);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.accessOrder = [];
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
private markAsUsed(key: K): void {
|
||||
this.removeFromAccessOrder(key);
|
||||
this.accessOrder.push(key);
|
||||
}
|
||||
|
||||
private removeFromAccessOrder(key: K): void {
|
||||
const index = this.accessOrder.indexOf(key);
|
||||
if (index > -1) {
|
||||
this.accessOrder.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private evictLeastUsed(): void {
|
||||
if (this.accessOrder.length > 0) {
|
||||
const leastUsed = this.accessOrder.shift()!;
|
||||
this.cache.delete(leastUsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
572
src/utils/colorModes.ts
Normal file
572
src/utils/colorModes.ts
Normal file
@ -0,0 +1,572 @@
|
||||
import {
|
||||
RGB,
|
||||
HSV,
|
||||
COLOR_TRANSITIONS,
|
||||
COLOR_MODE_CONSTANTS,
|
||||
} from './constants';
|
||||
|
||||
export function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= RGB.MAX_VALUE;
|
||||
g /= RGB.MAX_VALUE;
|
||||
b /= RGB.MAX_VALUE;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
|
||||
let h = 0;
|
||||
const s = max === 0 ? 0 : delta / max;
|
||||
const v = max;
|
||||
|
||||
if (delta !== 0) {
|
||||
if (max === r) {
|
||||
h = ((g - b) / delta) % HSV.HUE_SECTORS;
|
||||
} else if (max === g) {
|
||||
h = (b - r) / delta + HSV.SECTOR_OFFSETS.GREEN;
|
||||
} else {
|
||||
h = (r - g) / delta + HSV.SECTOR_OFFSETS.BLUE;
|
||||
}
|
||||
h /= HSV.HUE_SECTORS;
|
||||
}
|
||||
|
||||
if (h < 0) h += 1;
|
||||
|
||||
return [h, s, v];
|
||||
}
|
||||
|
||||
export function hsvToRgb(
|
||||
h: number,
|
||||
s: number,
|
||||
v: number
|
||||
): [number, number, number] {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h * HSV.HUE_SECTORS) % 2) - 1));
|
||||
const m = v - c;
|
||||
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
if (h < HSV.SECTOR_BOUNDARIES.SIXTH) {
|
||||
r = c;
|
||||
g = x;
|
||||
b = 0;
|
||||
} else if (h < HSV.SECTOR_BOUNDARIES.THIRD) {
|
||||
r = x;
|
||||
g = c;
|
||||
b = 0;
|
||||
} else if (h < HSV.SECTOR_BOUNDARIES.HALF) {
|
||||
r = 0;
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h < HSV.SECTOR_BOUNDARIES.TWO_THIRDS) {
|
||||
r = 0;
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h < HSV.SECTOR_BOUNDARIES.FIVE_SIXTHS) {
|
||||
r = x;
|
||||
g = 0;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
g = 0;
|
||||
b = x;
|
||||
}
|
||||
|
||||
return [
|
||||
Math.round((r + m) * RGB.MAX_VALUE),
|
||||
Math.round((g + m) * RGB.MAX_VALUE),
|
||||
Math.round((b + m) * RGB.MAX_VALUE),
|
||||
];
|
||||
}
|
||||
|
||||
export function applyHueShift(rgb: [number, number, number], hueShiftDegrees: number): [number, number, number] {
|
||||
if (hueShiftDegrees === 0) return rgb;
|
||||
|
||||
const [r, g, b] = rgb;
|
||||
const [h, s, v] = rgbToHsv(r, g, b);
|
||||
|
||||
let newHue = h + (hueShiftDegrees / 360);
|
||||
if (newHue > 1) newHue -= 1;
|
||||
if (newHue < 0) newHue += 1;
|
||||
|
||||
return hsvToRgb(newHue, s, v);
|
||||
}
|
||||
|
||||
export function rainbowColor(value: number): [number, number, number] {
|
||||
const phase = (value / RGB.MAX_VALUE) * COLOR_MODE_CONSTANTS.RAINBOW_PHASE_MULTIPLIER;
|
||||
const segment = Math.floor(phase);
|
||||
const remainder = phase - segment;
|
||||
const t = remainder;
|
||||
const q = 1 - t;
|
||||
|
||||
switch (segment % 6) {
|
||||
case 0:
|
||||
return [RGB.MAX_VALUE, Math.round(t * RGB.MAX_VALUE), 0];
|
||||
case 1:
|
||||
return [Math.round(q * RGB.MAX_VALUE), RGB.MAX_VALUE, 0];
|
||||
case 2:
|
||||
return [0, RGB.MAX_VALUE, Math.round(t * RGB.MAX_VALUE)];
|
||||
case 3:
|
||||
return [0, Math.round(q * RGB.MAX_VALUE), RGB.MAX_VALUE];
|
||||
case 4:
|
||||
return [Math.round(t * RGB.MAX_VALUE), 0, RGB.MAX_VALUE];
|
||||
case 5:
|
||||
return [RGB.MAX_VALUE, 0, Math.round(q * RGB.MAX_VALUE)];
|
||||
default:
|
||||
return [RGB.MAX_VALUE, RGB.MAX_VALUE, RGB.MAX_VALUE];
|
||||
}
|
||||
}
|
||||
|
||||
export function thermalColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
if (t < 0.25) {
|
||||
return [0, 0, Math.round(t * 4 * RGB.MAX_VALUE)];
|
||||
} else if (t < 0.5) {
|
||||
return [0, Math.round((t - 0.25) * 4 * RGB.MAX_VALUE), RGB.MAX_VALUE];
|
||||
} else if (t < 0.75) {
|
||||
return [
|
||||
Math.round((t - 0.5) * 4 * RGB.MAX_VALUE),
|
||||
RGB.MAX_VALUE,
|
||||
Math.round((0.75 - t) * 4 * RGB.MAX_VALUE),
|
||||
];
|
||||
} else {
|
||||
return [RGB.MAX_VALUE, RGB.MAX_VALUE, Math.round((t - 0.75) * 4 * RGB.MAX_VALUE)];
|
||||
}
|
||||
}
|
||||
|
||||
export function neonColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
const intensity = Math.pow(Math.sin(t * Math.PI), 2);
|
||||
const glow = Math.pow(intensity, 0.5);
|
||||
return [
|
||||
Math.round(glow * RGB.MAX_VALUE),
|
||||
Math.round(intensity * RGB.MAX_VALUE),
|
||||
Math.round(Math.pow(intensity, 2) * RGB.MAX_VALUE),
|
||||
];
|
||||
}
|
||||
|
||||
export function cyberpunkColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
const pulse = Math.sin(t * Math.PI * COLOR_TRANSITIONS.CYBERPUNK.PULSE_FREQUENCY) * COLOR_TRANSITIONS.CYBERPUNK.PULSE_AMPLITUDE + COLOR_TRANSITIONS.CYBERPUNK.PULSE_OFFSET;
|
||||
|
||||
if (t < COLOR_TRANSITIONS.CYBERPUNK.LOW) {
|
||||
return [Math.round(t * 5 * 50), 0, Math.round(t * 5 * 100)];
|
||||
} else if (t < COLOR_TRANSITIONS.CYBERPUNK.MID) {
|
||||
const p = (t - COLOR_TRANSITIONS.CYBERPUNK.LOW) / COLOR_TRANSITIONS.CYBERPUNK.LOW;
|
||||
return [
|
||||
Math.round(50 + p * 205 * pulse),
|
||||
Math.round(p * 50),
|
||||
Math.round(100 + p * 155)
|
||||
];
|
||||
} else if (t < COLOR_TRANSITIONS.CYBERPUNK.HIGH) {
|
||||
const p = (t - COLOR_TRANSITIONS.CYBERPUNK.MID) / (COLOR_TRANSITIONS.CYBERPUNK.HIGH - COLOR_TRANSITIONS.CYBERPUNK.MID);
|
||||
return [
|
||||
Math.round(RGB.MAX_VALUE * pulse),
|
||||
Math.round(50 + p * 205 * pulse),
|
||||
Math.round(RGB.MAX_VALUE - p * 100)
|
||||
];
|
||||
} else {
|
||||
const p = (t - 0.7) / 0.3;
|
||||
return [
|
||||
Math.round((RGB.MAX_VALUE - p * 155) * pulse),
|
||||
Math.round(RGB.MAX_VALUE * pulse),
|
||||
Math.round(155 + p * 100 * pulse)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function sunsetColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
if (t < 0.3) {
|
||||
return [Math.round(t * 3.33 * RGB.MAX_VALUE), 0, Math.round(t * 1.67 * RGB.MAX_VALUE)];
|
||||
} else if (t < 0.6) {
|
||||
const p = (t - 0.3) / 0.3;
|
||||
return [RGB.MAX_VALUE, Math.round(p * 100), Math.round(50 * (1 - p))];
|
||||
} else {
|
||||
const p = (t - 0.6) / 0.4;
|
||||
return [RGB.MAX_VALUE, Math.round(100 + p * 155), Math.round(p * 100)];
|
||||
}
|
||||
}
|
||||
|
||||
export function oceanColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
if (t < 0.25) {
|
||||
return [0, Math.round(t * 2 * RGB.MAX_VALUE), Math.round(100 + t * 4 * 155)];
|
||||
} else if (t < 0.5) {
|
||||
const p = (t - 0.25) / 0.25;
|
||||
return [0, Math.round(128 + p * 127), RGB.MAX_VALUE];
|
||||
} else if (t < 0.75) {
|
||||
const p = (t - 0.5) / 0.25;
|
||||
return [Math.round(p * 100), RGB.MAX_VALUE, Math.round(RGB.MAX_VALUE - p * 100)];
|
||||
} else {
|
||||
const p = (t - 0.75) / 0.25;
|
||||
return [Math.round(100 + p * 155), RGB.MAX_VALUE, Math.round(155 + p * 100)];
|
||||
}
|
||||
}
|
||||
|
||||
export function forestColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
if (t < 0.3) {
|
||||
return [Math.round(t * 2 * RGB.MAX_VALUE), Math.round(50 + t * 3 * 205), 0];
|
||||
} else if (t < 0.6) {
|
||||
const p = (t - 0.3) / 0.3;
|
||||
return [Math.round(150 - p * 100), RGB.MAX_VALUE, Math.round(p * 100)];
|
||||
} else {
|
||||
const p = (t - 0.6) / 0.4;
|
||||
return [Math.round(50 + p * 100), Math.round(RGB.MAX_VALUE - p * 100), Math.round(100 + p * 55)];
|
||||
}
|
||||
}
|
||||
|
||||
export function copperColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
if (t < 0.4) {
|
||||
return [Math.round(t * 2.5 * RGB.MAX_VALUE), Math.round(t * 1.5 * RGB.MAX_VALUE), Math.round(t * 0.5 * RGB.MAX_VALUE)];
|
||||
} else if (t < 0.7) {
|
||||
const p = (t - 0.4) / 0.3;
|
||||
return [RGB.MAX_VALUE, Math.round(153 + p * 102), Math.round(51 + p * 51)];
|
||||
} else {
|
||||
const p = (t - 0.7) / 0.3;
|
||||
return [RGB.MAX_VALUE, RGB.MAX_VALUE, Math.round(102 + p * 153)];
|
||||
}
|
||||
}
|
||||
|
||||
export function ditheredColor(value: number): [number, number, number] {
|
||||
const levels = COLOR_MODE_CONSTANTS.DITHER_LEVELS;
|
||||
const step = RGB.MAX_VALUE / (levels - 1);
|
||||
const quantized = Math.round(value / step) * step;
|
||||
const error = value - quantized;
|
||||
const dither = (Math.random() - 0.5) * COLOR_MODE_CONSTANTS.DITHER_NOISE_AMPLITUDE;
|
||||
const final = Math.max(RGB.MIN_VALUE, Math.min(RGB.MAX_VALUE, quantized + error + dither));
|
||||
return [final, final, final];
|
||||
}
|
||||
|
||||
export function paletteColor(value: number): [number, number, number] {
|
||||
const palette = [
|
||||
[0, 0, 0],
|
||||
[87, 29, 149],
|
||||
[191, 82, 177],
|
||||
[249, 162, 162],
|
||||
[RGB.MAX_VALUE, 241, 165],
|
||||
[134, 227, 206],
|
||||
[29, 161, 242],
|
||||
[RGB.MAX_VALUE, RGB.MAX_VALUE, RGB.MAX_VALUE],
|
||||
];
|
||||
const index = Math.floor((value / RGB.MAX_VALUE) * (palette.length - 1));
|
||||
return palette[index] as [number, number, number];
|
||||
}
|
||||
|
||||
export function vintageColor(value: number): [number, number, number] {
|
||||
const palette = [
|
||||
[25, 20, 15],
|
||||
[75, 54, 33],
|
||||
[124, 88, 56],
|
||||
[173, 129, 80],
|
||||
[222, 179, 120],
|
||||
[194, 154, 108],
|
||||
[166, 124, 82],
|
||||
[245, 222, 179],
|
||||
];
|
||||
const index = Math.floor((value / RGB.MAX_VALUE) * (palette.length - 1));
|
||||
return palette[index] as [number, number, number];
|
||||
}
|
||||
|
||||
export function plasmaColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
const freq = 2.4;
|
||||
const phase1 = 0.0;
|
||||
const phase2 = 2.094;
|
||||
const phase3 = 4.188;
|
||||
|
||||
const r = Math.sin(freq * t + phase1) * 0.5 + 0.5;
|
||||
const g = Math.sin(freq * t + phase2) * 0.5 + 0.5;
|
||||
const b = Math.sin(freq * t + phase3) * 0.5 + 0.5;
|
||||
|
||||
return [
|
||||
Math.round(r * RGB.MAX_VALUE),
|
||||
Math.round(g * RGB.MAX_VALUE),
|
||||
Math.round(b * RGB.MAX_VALUE)
|
||||
];
|
||||
}
|
||||
|
||||
export function fireColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
if (t < 0.2) {
|
||||
return [Math.round(t * 5 * RGB.MAX_VALUE), 0, 0];
|
||||
} else if (t < 0.5) {
|
||||
const p = (t - 0.2) / 0.3;
|
||||
return [RGB.MAX_VALUE, Math.round(p * 165), 0];
|
||||
} else if (t < 0.8) {
|
||||
const p = (t - 0.5) / 0.3;
|
||||
return [RGB.MAX_VALUE, Math.round(165 + p * 90), Math.round(p * 100)];
|
||||
} else {
|
||||
const p = (t - 0.8) / 0.2;
|
||||
return [RGB.MAX_VALUE, RGB.MAX_VALUE, Math.round(100 + p * 155)];
|
||||
}
|
||||
}
|
||||
|
||||
export function iceColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
if (t < 0.25) {
|
||||
return [Math.round(t * 2 * RGB.MAX_VALUE), Math.round(t * 3 * RGB.MAX_VALUE), RGB.MAX_VALUE];
|
||||
} else if (t < 0.5) {
|
||||
const p = (t - 0.25) / 0.25;
|
||||
return [Math.round(128 + p * 127), Math.round(192 + p * 63), RGB.MAX_VALUE];
|
||||
} else if (t < 0.75) {
|
||||
const p = (t - 0.5) / 0.25;
|
||||
return [RGB.MAX_VALUE, RGB.MAX_VALUE, Math.round(RGB.MAX_VALUE - p * 100)];
|
||||
} else {
|
||||
const p = (t - 0.75) / 0.25;
|
||||
return [RGB.MAX_VALUE, RGB.MAX_VALUE, Math.round(155 + p * 100)];
|
||||
}
|
||||
}
|
||||
|
||||
export function infraredColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
const intensity = Math.pow(t, 0.6);
|
||||
const heat = Math.sin(t * Math.PI * 1.5) * 0.5 + 0.5;
|
||||
|
||||
const r = Math.round(RGB.MAX_VALUE * intensity);
|
||||
const g = Math.round(128 * heat * intensity);
|
||||
const b = Math.round(64 * (1 - intensity) * heat);
|
||||
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
export function xrayColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
const inverted = 1.0 - t;
|
||||
const contrast = Math.pow(inverted, 1.8);
|
||||
const glow = Math.sin(t * Math.PI) * 0.3;
|
||||
|
||||
const intensity = Math.round(contrast * RGB.MAX_VALUE);
|
||||
const cyan = Math.round((contrast + glow) * 180);
|
||||
const blue = Math.round((contrast + glow * 0.5) * 120);
|
||||
|
||||
return [intensity, cyan, blue];
|
||||
}
|
||||
|
||||
export function spectrumColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
const hue = t * 360;
|
||||
const saturation = 0.7;
|
||||
const lightness = 0.6 + (Math.sin(t * Math.PI * 4) * 0.2);
|
||||
|
||||
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const x = c * (1 - Math.abs((hue / 60) % 2 - 1));
|
||||
const m = lightness - c / 2;
|
||||
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (hue < 60) {
|
||||
r = c; g = x; b = 0;
|
||||
} else if (hue < 120) {
|
||||
r = x; g = c; b = 0;
|
||||
} else if (hue < 180) {
|
||||
r = 0; g = c; b = x;
|
||||
} else if (hue < 240) {
|
||||
r = 0; g = x; b = c;
|
||||
} else if (hue < 300) {
|
||||
r = x; g = 0; b = c;
|
||||
} else {
|
||||
r = c; g = 0; b = x;
|
||||
}
|
||||
|
||||
return [
|
||||
Math.round((r + m) * RGB.MAX_VALUE),
|
||||
Math.round((g + m) * RGB.MAX_VALUE),
|
||||
Math.round((b + m) * RGB.MAX_VALUE)
|
||||
];
|
||||
}
|
||||
|
||||
export function acidColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
const phase = t * Math.PI * 2;
|
||||
|
||||
const r = Math.sin(phase) * 0.5 + 0.5;
|
||||
const g = Math.sin(phase + Math.PI * 0.66) * 0.5 + 0.5;
|
||||
const b = Math.sin(phase + Math.PI * 1.33) * 0.5 + 0.5;
|
||||
|
||||
const intensity = Math.pow(t, 0.8);
|
||||
const glow = Math.sin(t * Math.PI * 6) * 0.2 + 0.8;
|
||||
|
||||
return [
|
||||
Math.round(r * intensity * glow * RGB.MAX_VALUE),
|
||||
Math.round(g * intensity * glow * RGB.MAX_VALUE),
|
||||
Math.round(b * intensity * glow * RGB.MAX_VALUE)
|
||||
];
|
||||
}
|
||||
|
||||
export function palette16Color(value: number): [number, number, number] {
|
||||
const palette = [
|
||||
[0, 0, 0], // Black
|
||||
[RGB.MAX_VALUE, RGB.MAX_VALUE, RGB.MAX_VALUE], // White
|
||||
[RGB.MAX_VALUE, 0, 0], // Red
|
||||
[0, RGB.MAX_VALUE, 0], // Green
|
||||
[0, 0, RGB.MAX_VALUE], // Blue
|
||||
[RGB.MAX_VALUE, RGB.MAX_VALUE, 0], // Yellow
|
||||
[RGB.MAX_VALUE, 0, RGB.MAX_VALUE], // Magenta
|
||||
[0, RGB.MAX_VALUE, RGB.MAX_VALUE], // Cyan
|
||||
[RGB.MAX_VALUE, 128, 0], // Orange
|
||||
[128, 0, RGB.MAX_VALUE], // Purple
|
||||
[0, RGB.MAX_VALUE, 128], // Spring Green
|
||||
[RGB.MAX_VALUE, 0, 128], // Pink
|
||||
[128, RGB.MAX_VALUE, 0], // Lime
|
||||
[0, 128, RGB.MAX_VALUE], // Sky Blue
|
||||
[128, 128, 128], // Gray
|
||||
[192, 192, 192] // Light Gray
|
||||
];
|
||||
|
||||
const index = Math.floor((value / RGB.MAX_VALUE) * (palette.length - 1));
|
||||
return palette[index] as [number, number, number];
|
||||
}
|
||||
|
||||
export function quantumColor(value: number): [number, number, number] {
|
||||
const palette = [
|
||||
[0, 0, 0], // Void Black
|
||||
[128, 0, RGB.MAX_VALUE], // Quantum Purple
|
||||
[0, RGB.MAX_VALUE, 128], // Energy Green
|
||||
[RGB.MAX_VALUE, RGB.MAX_VALUE, RGB.MAX_VALUE] // Pure White
|
||||
];
|
||||
|
||||
const index = Math.floor((value / RGB.MAX_VALUE) * (palette.length - 1));
|
||||
return palette[index] as [number, number, number];
|
||||
}
|
||||
|
||||
export function neonStrikeColor(value: number): [number, number, number] {
|
||||
const palette = [
|
||||
[10, 0, 20], // Deep Dark
|
||||
[RGB.MAX_VALUE, 20, 147], // Hot Pink
|
||||
[0, RGB.MAX_VALUE, RGB.MAX_VALUE], // Electric Cyan
|
||||
[RGB.MAX_VALUE, RGB.MAX_VALUE, 0], // Neon Yellow
|
||||
[RGB.MAX_VALUE, RGB.MAX_VALUE, RGB.MAX_VALUE] // Blinding White
|
||||
];
|
||||
|
||||
const index = Math.floor((value / RGB.MAX_VALUE) * (palette.length - 1));
|
||||
return palette[index] as [number, number, number];
|
||||
}
|
||||
|
||||
export function eightBitColor(value: number): [number, number, number] {
|
||||
const levels = 4;
|
||||
const r = Math.floor((value / RGB.MAX_VALUE) * levels) * (RGB.MAX_VALUE / (levels - 1));
|
||||
const g = Math.floor(((value * 2) % 256 / RGB.MAX_VALUE) * levels) * (RGB.MAX_VALUE / (levels - 1));
|
||||
const b = Math.floor(((value * 3) % 256 / RGB.MAX_VALUE) * levels) * (RGB.MAX_VALUE / (levels - 1));
|
||||
|
||||
return [
|
||||
Math.min(RGB.MAX_VALUE, Math.max(0, r)),
|
||||
Math.min(RGB.MAX_VALUE, Math.max(0, g)),
|
||||
Math.min(RGB.MAX_VALUE, Math.max(0, b))
|
||||
];
|
||||
}
|
||||
|
||||
export function silkColor(value: number): [number, number, number] {
|
||||
const t = value / RGB.MAX_VALUE;
|
||||
const smoothT = t * t * (3.0 - 2.0 * t);
|
||||
|
||||
const r = Math.sin(smoothT * Math.PI * 2.0) * 0.5 + 0.5;
|
||||
const g = Math.sin(smoothT * Math.PI * 2.0 + Math.PI * 0.66) * 0.5 + 0.5;
|
||||
const b = Math.sin(smoothT * Math.PI * 2.0 + Math.PI * 1.33) * 0.5 + 0.5;
|
||||
|
||||
const fade = Math.pow(smoothT, 0.3);
|
||||
|
||||
return [
|
||||
Math.round(r * fade * RGB.MAX_VALUE),
|
||||
Math.round(g * fade * RGB.MAX_VALUE),
|
||||
Math.round(b * fade * RGB.MAX_VALUE)
|
||||
];
|
||||
}
|
||||
|
||||
export function binaryColor(value: number): [number, number, number] {
|
||||
const threshold = COLOR_MODE_CONSTANTS.BINARY_THRESHOLD;
|
||||
return value < threshold
|
||||
? [RGB.MIN_VALUE, RGB.MIN_VALUE, RGB.MIN_VALUE] // Pure Black
|
||||
: [RGB.MAX_VALUE, RGB.MAX_VALUE, RGB.MAX_VALUE]; // Pure White
|
||||
}
|
||||
|
||||
|
||||
export function palette32Color(value: number): [number, number, number] {
|
||||
const palette = [
|
||||
[0, 0, 0], // Black
|
||||
[RGB.MAX_VALUE, RGB.MAX_VALUE, RGB.MAX_VALUE], // White
|
||||
[RGB.MAX_VALUE, 0, 0], // Red
|
||||
[0, RGB.MAX_VALUE, 0], // Green
|
||||
[0, 0, RGB.MAX_VALUE], // Blue
|
||||
[RGB.MAX_VALUE, RGB.MAX_VALUE, 0], // Yellow
|
||||
[RGB.MAX_VALUE, 0, RGB.MAX_VALUE], // Magenta
|
||||
[0, RGB.MAX_VALUE, RGB.MAX_VALUE], // Cyan
|
||||
[RGB.MAX_VALUE, 128, 0], // Orange
|
||||
[128, 0, RGB.MAX_VALUE], // Purple
|
||||
[0, RGB.MAX_VALUE, 128], // Spring Green
|
||||
[RGB.MAX_VALUE, 0, 128], // Pink
|
||||
[128, RGB.MAX_VALUE, 0], // Lime
|
||||
[0, 128, RGB.MAX_VALUE], // Sky Blue
|
||||
[128, 128, 128], // Gray
|
||||
[192, 192, 192], // Light Gray
|
||||
[64, 64, 64], // Dark Gray
|
||||
[128, 64, 0], // Brown
|
||||
[64, 128, 0], // Olive
|
||||
[0, 64, 128], // Navy
|
||||
[128, 0, 64], // Maroon
|
||||
[64, 0, 128], // Indigo
|
||||
[0, 128, 64], // Teal
|
||||
[RGB.MAX_VALUE, 192, 128], // Peach
|
||||
[128, RGB.MAX_VALUE, 192], // Mint
|
||||
[192, 128, RGB.MAX_VALUE], // Lavender
|
||||
[RGB.MAX_VALUE, 128, 192], // Rose
|
||||
[128, 192, RGB.MAX_VALUE], // Light Blue
|
||||
[192, RGB.MAX_VALUE, 128], // Light Green
|
||||
[64, 32, 16], // Dark Brown
|
||||
[16, 64, 32], // Forest
|
||||
[32, 16, 64] // Deep Purple
|
||||
];
|
||||
|
||||
const index = Math.floor((value / RGB.MAX_VALUE) * (palette.length - 1));
|
||||
return palette[index] as [number, number, number];
|
||||
}
|
||||
|
||||
// Color palette registry - automatically maps render modes to color functions
|
||||
const COLOR_PALETTE_REGISTRY: Record<string, (value: number) => [number, number, number]> = {
|
||||
classic: (value) => [value, (value * 2) % 256, (value * 3) % 256],
|
||||
grayscale: (value) => [value, value, value],
|
||||
red: (value) => [value, 0, 0],
|
||||
green: (value) => [0, value, 0],
|
||||
blue: (value) => [0, 0, value],
|
||||
rgb: (value) => [value, (value * 2) % 256, (value * 3) % 256], // Same as classic for now
|
||||
forest: forestColor,
|
||||
copper: copperColor,
|
||||
rainbow: rainbowColor,
|
||||
thermal: thermalColor,
|
||||
neon: neonColor,
|
||||
cyberpunk: cyberpunkColor,
|
||||
vaporwave: plasmaColor, // Use plasma for vaporwave theme
|
||||
sunset: sunsetColor,
|
||||
ocean: oceanColor,
|
||||
dithered: ditheredColor,
|
||||
palette: paletteColor,
|
||||
vintage: vintageColor,
|
||||
plasma: plasmaColor,
|
||||
fire: fireColor,
|
||||
ice: iceColor,
|
||||
infrared: infraredColor,
|
||||
xray: xrayColor,
|
||||
spectrum: spectrumColor,
|
||||
acid: acidColor,
|
||||
quantum: quantumColor,
|
||||
neonstrike: neonStrikeColor,
|
||||
eightbit: eightBitColor,
|
||||
silk: silkColor,
|
||||
binary: binaryColor,
|
||||
palette16: palette16Color,
|
||||
palette32: palette32Color,
|
||||
};
|
||||
|
||||
export function calculateColorDirect(
|
||||
absValue: number,
|
||||
renderMode: string,
|
||||
hueShift: number = 0
|
||||
): [number, number, number] {
|
||||
const colorFunction = COLOR_PALETTE_REGISTRY[renderMode];
|
||||
const color = colorFunction ? colorFunction(absValue) : [absValue, absValue, absValue] as [number, number, number];
|
||||
|
||||
return applyHueShift(color, hueShift);
|
||||
}
|
||||
230
src/utils/constants.ts
Normal file
230
src/utils/constants.ts
Normal file
@ -0,0 +1,230 @@
|
||||
// UI Layout Constants
|
||||
export const UI_HEIGHTS = {
|
||||
TOP_BAR: 40,
|
||||
EDITOR_PANEL: 140,
|
||||
TOTAL_UI_HEIGHT: 180, // TOP_BAR + EDITOR_PANEL
|
||||
} as const;
|
||||
|
||||
// Performance Constants
|
||||
export const PERFORMANCE = {
|
||||
DEFAULT_TILE_SIZE: 128,
|
||||
MAX_RENDER_TIME_MS: 50,
|
||||
MAX_SHADER_TIMEOUT_MS: 5,
|
||||
TIMEOUT_CHECK_INTERVAL: 1000,
|
||||
MAX_SAVED_SHADERS: 50,
|
||||
IMAGE_DATA_CACHE_SIZE: 10,
|
||||
COMPILATION_CACHE_SIZE: 30,
|
||||
} as const;
|
||||
|
||||
// Color Constants
|
||||
export const COLOR_TABLE_SIZE = 256;
|
||||
|
||||
// Color Calculation Constants
|
||||
export const RGB = {
|
||||
MAX_VALUE: 255,
|
||||
MIN_VALUE: 0,
|
||||
} as const;
|
||||
|
||||
// Luminance calculation constants (ITU-R BT.709)
|
||||
export const LUMINANCE_WEIGHTS = {
|
||||
RED: 0.299,
|
||||
GREEN: 0.587,
|
||||
BLUE: 0.114,
|
||||
} as const;
|
||||
|
||||
// HSV Color Constants
|
||||
export const HSV = {
|
||||
HUE_SECTORS: 6,
|
||||
HUE_MAX_DEGREES: 360,
|
||||
SECTOR_OFFSETS: {
|
||||
GREEN: 2,
|
||||
BLUE: 4,
|
||||
},
|
||||
SECTOR_BOUNDARIES: {
|
||||
SIXTH: 1/6,
|
||||
THIRD: 2/6,
|
||||
HALF: 3/6,
|
||||
TWO_THIRDS: 4/6,
|
||||
FIVE_SIXTHS: 5/6,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Color Transition Thresholds
|
||||
export const COLOR_TRANSITIONS = {
|
||||
THERMAL: {
|
||||
LOW: 0.25,
|
||||
MID: 0.5,
|
||||
HIGH: 0.75,
|
||||
},
|
||||
CYBERPUNK: {
|
||||
LOW: 0.2,
|
||||
MID: 0.4,
|
||||
HIGH: 0.7,
|
||||
PULSE_FREQUENCY: 8,
|
||||
PULSE_AMPLITUDE: 0.3,
|
||||
PULSE_OFFSET: 0.7,
|
||||
},
|
||||
SUNSET: {
|
||||
LOW: 0.3,
|
||||
HIGH: 0.6,
|
||||
},
|
||||
FIRE: {
|
||||
LOW: 0.2,
|
||||
MID: 0.5,
|
||||
HIGH: 0.8,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Color Mode Specific Constants
|
||||
export const COLOR_MODE_CONSTANTS = {
|
||||
DITHER_LEVELS: 4,
|
||||
DITHER_NOISE_AMPLITUDE: 32,
|
||||
BINARY_THRESHOLD: 128,
|
||||
RAINBOW_PHASE_MULTIPLIER: 6,
|
||||
PLASMA: {
|
||||
FREQUENCY_X: 2.4,
|
||||
FREQUENCY_Y: 2.094,
|
||||
FREQUENCY_Z: 4.188,
|
||||
PHASE_OFFSET: 0.0,
|
||||
},
|
||||
INFRARED: {
|
||||
INTENSITY_POWER: 0.6,
|
||||
HEAT_FREQUENCY: 1.5,
|
||||
},
|
||||
XRAY: {
|
||||
CONTRAST_POWER: 1.8,
|
||||
},
|
||||
SPECTRUM: {
|
||||
HUE_DEGREES: 360,
|
||||
SATURATION: 0.7,
|
||||
LIGHTNESS_BASE: 0.6,
|
||||
LIGHTNESS_FREQUENCY: 4,
|
||||
LIGHTNESS_AMPLITUDE: 0.2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Render Mode Constants - Keep in sync with color modes
|
||||
export const RENDER_MODES = [
|
||||
'classic',
|
||||
'grayscale',
|
||||
'red',
|
||||
'green',
|
||||
'blue',
|
||||
'rgb',
|
||||
'rainbow',
|
||||
'thermal',
|
||||
'neon',
|
||||
'cyberpunk',
|
||||
'vaporwave',
|
||||
'dithered',
|
||||
'palette',
|
||||
'sunset',
|
||||
'ocean',
|
||||
'forest',
|
||||
'copper',
|
||||
'vintage',
|
||||
'infrared',
|
||||
'fire',
|
||||
'ice',
|
||||
'plasma',
|
||||
'xray',
|
||||
'spectrum',
|
||||
'acid',
|
||||
'quantum',
|
||||
'neonstrike',
|
||||
'eightbit',
|
||||
'silk',
|
||||
'binary',
|
||||
'palette16',
|
||||
'palette32',
|
||||
] as const;
|
||||
|
||||
export type RenderMode = (typeof RENDER_MODES)[number];
|
||||
|
||||
// Create a mapping from render mode to index for O(1) lookups
|
||||
export const RENDER_MODE_INDEX: Record<string, number> = RENDER_MODES.reduce(
|
||||
(acc, mode, index) => {
|
||||
acc[mode] = index;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
// Storage Keys
|
||||
export const STORAGE_KEYS = {
|
||||
SHADERS: 'bitfielder_shaders',
|
||||
SETTINGS: 'bitfielder_settings',
|
||||
} as const;
|
||||
|
||||
// Value Modes
|
||||
export const VALUE_MODES = [
|
||||
'integer',
|
||||
'float',
|
||||
'polar',
|
||||
'distance',
|
||||
'wave',
|
||||
'fractal',
|
||||
'cellular',
|
||||
'noise',
|
||||
'warp',
|
||||
'flow',
|
||||
'spiral',
|
||||
'turbulence',
|
||||
'crystal',
|
||||
'marble',
|
||||
'quantum',
|
||||
'logarithmic',
|
||||
'mirror',
|
||||
'rings',
|
||||
'mesh',
|
||||
'glitch',
|
||||
'diffusion',
|
||||
'cascade',
|
||||
'echo',
|
||||
'mosh',
|
||||
'fold',
|
||||
] as const;
|
||||
|
||||
export type ValueMode = (typeof VALUE_MODES)[number];
|
||||
|
||||
// Frame Rate and Timing Constants
|
||||
export const TIMING = {
|
||||
DEFAULT_FPS: 30,
|
||||
MIN_FPS: 1,
|
||||
MAX_FPS: 120,
|
||||
MILLISECONDS_PER_SECOND: 1000,
|
||||
DEFAULT_TIME_SPEED: 1.0,
|
||||
DEFAULT_BPM: 120,
|
||||
} as const;
|
||||
|
||||
// Worker and Threading Constants
|
||||
export const WORKER = {
|
||||
FALLBACK_CORE_COUNT: 4,
|
||||
MAX_WORKERS: 32,
|
||||
DEFAULT_PINCH_SCALE: 1,
|
||||
} as const;
|
||||
|
||||
// Mathematical Constants
|
||||
export const MATH = {
|
||||
DEGREES_IN_CIRCLE: 360,
|
||||
RADIANS_TO_DEGREES: 180 / Math.PI,
|
||||
TWO_PI: 2 * Math.PI,
|
||||
} as const;
|
||||
|
||||
// JSON and String Constants
|
||||
export const FORMAT = {
|
||||
JSON_INDENT: 2,
|
||||
ID_RADIX: 36,
|
||||
ID_SUBSTRING_START: 2,
|
||||
} as const;
|
||||
|
||||
// Default Values
|
||||
export const DEFAULTS = {
|
||||
RESOLUTION: 8,
|
||||
FPS: 30,
|
||||
RENDER_MODE: 'classic',
|
||||
VALUE_MODE: 'integer' as ValueMode,
|
||||
UI_OPACITY: 0.3,
|
||||
SHADER_CODE: 'x^y',
|
||||
TIME_SPEED: 1.0,
|
||||
} as const;
|
||||
@ -10,10 +10,11 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@ -1,11 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
server: {
|
||||
port: 3000
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
preview: {
|
||||
port: 4173,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['bitfielder.raphaelforment.fr']
|
||||
},
|
||||
worker: {
|
||||
|
||||
Reference in New Issue
Block a user