Compare commits

...

23 Commits

Author SHA1 Message Date
9db1479cc0 update the welcome popup 2025-07-15 01:20:24 +02:00
2cee4084c0 optimisations 2025-07-15 00:52:00 +02:00
3eeafc1277 wip 2025-07-14 21:58:04 +02:00
431966d498 essais 2025-07-14 21:08:21 +02:00
2cf306ee8c i dunno 2025-07-14 12:56:16 +02:00
d64b3839e8 refactor help (more) 2025-07-14 12:30:34 +02:00
80537a4a30 refactor help 2025-07-14 12:19:41 +02:00
9bf5d40171 config error 2025-07-08 15:08:41 +02:00
3dcbbfd9c2 fix for build 2025-07-08 13:52:28 +02:00
e0e860f9c9 improving share mechanism 2025-07-08 13:51:58 +02:00
fb2d5c1b4c small size update with tons of rendering modes, palettes and keybindings 2025-07-07 21:40:19 +02:00
6d5aa9f0f5 ok le merge 2025-07-07 15:32:51 +00:00
10b70ffc54 ok test 2025-07-07 15:29:41 +00:00
8aad6554ed small optimizations 2025-07-06 17:11:35 +02:00
bf5085431a Fixing share link 2025-07-06 15:33:35 +02:00
3a5b38bd70 introducing a lot of new operators 2025-07-06 15:03:51 +02:00
ec8786ab9b switching 2025-07-06 13:11:19 +02:00
f84b515523 lots of updates 2025-07-06 01:14:43 +02:00
96af50ee6b new color palette 2025-07-05 22:07:46 +00:00
afd0fd84f6 backtrack 2025-07-05 21:55:50 +00:00
64bea69036 modify config 2025-07-05 21:34:30 +00:00
b0f650f243 more workers 2025-07-05 23:13:13 +02:00
f7054d8300 some easy wins 2025-07-05 23:02:15 +02:00
53 changed files with 13210 additions and 2717 deletions

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.js
public

38
.eslintrc.json Normal file
View 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
View File

@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}

View File

@ -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

View File

@ -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)))

View File

@ -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">&times;</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>&lt;&lt; &gt;&gt;</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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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 });
}
});

View File

@ -1,348 +1,612 @@
interface WorkerMessage {
id: string;
type: 'compile' | 'render';
code?: string;
width?: number;
height?: number;
time?: number;
renderMode?: string;
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;
}
import { WorkerMessage, WorkerResponse } from './shader/types';
import { TIMING, WORKER, DEFAULTS } from './utils/constants';
export class FakeShader {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private code: string;
private worker: Worker;
private animationId: number | null = null;
private startTime: number = Date.now();
private isCompiled: boolean = false;
private isRendering: boolean = false;
private pendingRenders: string[] = [];
private renderMode: string = 'classic';
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 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 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;
// Frame rate limiting
private targetFPS: number = 30;
private frameInterval: number = 1000 / this.targetFPS;
private lastFrameTime: number = 0;
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private code: string;
private worker: Worker; // Single worker for backwards compatibility
private workers: Worker[] = [];
private workerCount: number;
private animationId: number | null = null;
private startTime: number = Date.now();
private isCompiled: boolean = false;
private isRendering: boolean = false;
private pendingRenders: string[] = [];
private renderMode: string = 'classic';
private valueMode: string = 'integer';
private hueShift: number = 0;
private timeSpeed: number = DEFAULTS.TIME_SPEED;
private currentBPM: number = TIMING.DEFAULT_BPM;
constructor(canvas: HTMLCanvasElement, code: string = 'x^y') {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.code = code;
// Initialize worker
this.worker = new Worker(new URL('./ShaderWorker.ts', import.meta.url), { type: 'module' });
this.worker.onmessage = (e: MessageEvent<WorkerResponse>) => this.handleWorkerMessage(e.data);
this.worker.onerror = (error) => console.error('Worker error:', error);
this.compile();
// ID generation optimization
private idCounter: number = 0;
// Reusable message object to avoid allocations
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: TIMING.DEFAULT_BPM,
startY: 0,
};
// Multi-worker state
private tileResults: Map<number, ImageData> = new Map();
private tilesCompleted: number = 0;
private totalTiles: number = 0;
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 touchCount: number = 0;
private touch0X: number = 0;
private touch0Y: number = 0;
private touch1X: number = 0;
private touch1Y: number = 0;
private pinchScale: number = WORKER.DEFAULT_PINCH_SCALE;
private pinchRotation: 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;
// Frame rate limiting
private targetFPS: number = TIMING.DEFAULT_FPS;
private frameInterval: number = TIMING.MILLISECONDS_PER_SECOND / this.targetFPS;
private lastFrameTime: number = 0;
constructor(canvas: HTMLCanvasElement, code: string = 'x^y') {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.code = code;
// Initialize offscreen canvas if supported
this.initializeOffscreenCanvas();
// Always use maximum available cores
this.workerCount = navigator.hardwareConcurrency || WORKER.FALLBACK_CORE_COUNT;
// Some browsers report logical processors (hyperthreading), which is good
// But cap at a reasonable maximum to avoid overhead
this.workerCount = Math.min(this.workerCount, WORKER.MAX_WORKERS);
console.log(
`Auto-detected ${this.workerCount} CPU cores, using all for maximum performance`
);
// Initialize workers
this.initializeWorkers();
// Keep single worker reference for backwards compatibility
this.worker = this.workers[0];
this.compile();
}
private initializeOffscreenCanvas(): void {
if (typeof OffscreenCanvas !== 'undefined') {
try {
// this.offscreenCanvas = new OffscreenCanvas(this.canvas.width, this.canvas.height); // Removed unused
// this._offscreenCtx = this.offscreenCanvas.getContext('2d'); // Removed unused
// this._useOffscreen = this._offscreenCtx !== null; // Removed unused property
} catch (error) {
console.warn('OffscreenCanvas not supported:', error);
// this._useOffscreen = false; // Removed unused property
}
}
}
private handleWorkerMessage(response: WorkerResponse): void {
switch (response.type) {
case 'compiled':
this.isCompiled = response.success;
if (!response.success) {
console.error('Compilation failed:', response.error);
this.fillBlack();
}
break;
case 'rendered':
this.isRendering = false;
if (response.success && response.imageData) {
this.ctx.putImageData(response.imageData, 0, 0);
} else {
console.error('Render failed:', response.error);
this.fillBlack();
}
// Process pending renders
if (this.pendingRenders.length > 0) {
this.pendingRenders.shift(); // Remove completed render
if (this.pendingRenders.length > 0) {
// Skip to latest render request
const latestId = this.pendingRenders[this.pendingRenders.length - 1];
this.pendingRenders = [latestId];
this.executeRender(latestId);
}
}
break;
case 'error':
this.isRendering = false;
console.error('Worker error:', response.error);
this.fillBlack();
break;
private initializeWorkers(): void {
// Create worker pool
for (let i = 0; i < this.workerCount; i++) {
const worker = new Worker(new URL('./shader/worker/ShaderWorker.ts', import.meta.url), {
type: 'module',
});
worker.onmessage = (e: MessageEvent<WorkerResponse>) =>
this.handleWorkerMessage(e.data, i);
worker.onerror = (error) => console.error(`Worker ${i} error:`, error);
this.workers.push(worker);
}
}
private handleWorkerMessage(
response: WorkerResponse,
workerIndex: number = 0
): void {
switch (response.type) {
case 'compiled':
this.isCompiled = response.success;
if (!response.success) {
console.error('Compilation failed:', response.error);
this.fillBlack();
}
}
break;
private fillBlack(): void {
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
private compile(): void {
this.isCompiled = false;
const id = `compile_${Date.now()}`;
this.worker.postMessage({
id,
type: 'compile',
code: this.code
} as WorkerMessage);
}
private executeRender(id: string): void {
if (!this.isCompiled || this.isRendering) {
return;
}
this.isRendering = true;
const currentTime = (Date.now() - this.startTime) / 1000;
this.worker.postMessage({
id,
type: 'render',
width: this.canvas.width,
height: this.canvas.height,
time: currentTime,
renderMode: this.renderMode,
mouseX: this.mouseX,
mouseY: this.mouseY,
mousePressed: this.mousePressed,
mouseVX: this.mouseVX,
mouseVY: this.mouseVY,
mouseClickTime: this.mouseClickTime,
touchCount: this.touchCount,
touch0X: this.touch0X,
touch0Y: this.touch0Y,
touch1X: this.touch1X,
touch1Y: this.touch1Y,
pinchScale: this.pinchScale,
pinchRotation: this.pinchRotation,
accelX: this.accelX,
accelY: this.accelY,
accelZ: this.accelZ,
gyroX: this.gyroX,
gyroY: this.gyroY,
gyroZ: this.gyroZ,
audioLevel: this.audioLevel,
bassLevel: this.bassLevel,
midLevel: this.midLevel,
trebleLevel: this.trebleLevel
} as WorkerMessage);
}
setCode(code: string): void {
this.code = code;
this.compile();
}
render(animate: boolean = false): void {
const currentTime = performance.now();
// Frame rate limiting
if (animate && currentTime - this.lastFrameTime < this.frameInterval) {
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
return;
}
this.lastFrameTime = currentTime;
if (!this.isCompiled) {
this.fillBlack();
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
return;
}
const renderId = `render_${Date.now()}_${Math.random()}`;
// Add to pending renders queue
this.pendingRenders.push(renderId);
// If not currently rendering, start immediately
if (!this.isRendering) {
this.executeRender(renderId);
}
// Continue animation
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
}
startAnimation(): void {
this.stopAnimation();
this.startTime = Date.now();
this.lastFrameTime = 0; // Reset frame timing
this.render(true);
}
stopAnimation(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
// Clear pending renders
this.pendingRenders = [];
}
setTargetFPS(fps: number): void {
this.targetFPS = Math.max(1, Math.min(120, fps)); // Clamp between 1-120 FPS
this.frameInterval = 1000 / this.targetFPS;
}
setRenderMode(mode: string): void {
this.renderMode = mode;
}
setMousePosition(x: number, y: number, pressed: boolean = false, vx: number = 0, vy: number = 0, clickTime: number = 0): void {
this.mouseX = x;
this.mouseY = y;
this.mousePressed = pressed;
this.mouseVX = vx;
this.mouseVY = vy;
this.mouseClickTime = clickTime;
}
setTouchPosition(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;
}
setDeviceMotion(ax: number, ay: number, az: number, gx: number, gy: number, gz: number): void {
this.accelX = ax;
this.accelY = ay;
this.accelZ = az;
this.gyroX = gx;
this.gyroY = gy;
this.gyroZ = gz;
}
setAudioData(level: number, bass: number, mid: number, treble: number): void {
this.audioLevel = level;
this.bassLevel = bass;
this.midLevel = mid;
this.trebleLevel = treble;
}
destroy(): void {
this.stopAnimation();
this.worker.terminate();
}
static generateRandomCode(): string {
const presets = [
'x^y',
'x&y',
'x|y',
'(x*y)%256',
'(x+y+t*10)%256',
'((x>>4)^(y>>4))<<4',
'(x^y^(x*y))%256',
'((x&y)|(x^y))%256',
'(x+y)&255',
'x%y',
'(x^(y<<2))%256',
'((x*t)^y)%256',
'(x&(y|t*8))%256',
'((x>>2)|(y<<2))%256',
'(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'
];
const vars = ['x', 'y', 't', 'i'];
const ops = ['^', '&', '|', '+', '-', '*', '%'];
const shifts = ['<<', '>>'];
const numbers = ['2', '4', '8', '16', '32', '64', '128', '256'];
const randomChoice = <T>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
const dynamicExpressions = [
() => `${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)}`,
() => `(${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)})%${randomChoice(numbers)}`,
() => `${randomChoice(vars)}${randomChoice(shifts)}${Math.floor(Math.random() * 8)}`,
() => `(${randomChoice(vars)}*${randomChoice(vars)})%${randomChoice(numbers)}`,
() => `${randomChoice(vars)}^${randomChoice(vars)}^${randomChoice(vars)}`,
];
// 70% chance to pick from presets, 30% chance to generate dynamic
if (Math.random() < 0.7) {
return randomChoice(presets);
case 'rendered':
if (this.workerCount > 1) {
this.handleTileResult(response, workerIndex);
} else {
return randomChoice(dynamicExpressions)();
// Single worker mode
this.isRendering = false;
if (response.success && response.imageData) {
// Put ImageData directly on main canvas
this.ctx.putImageData(response.imageData, 0, 0);
} else {
console.error('Render failed:', response.error);
this.fillBlack();
}
}
// Process pending renders
if (this.pendingRenders.length > 0) {
this.pendingRenders.shift(); // Remove completed render
if (this.pendingRenders.length > 0) {
// Skip to latest render request
const latestId =
this.pendingRenders[this.pendingRenders.length - 1];
this.pendingRenders = [latestId];
this.executeRender(latestId);
}
}
break;
case 'error':
this.isRendering = false;
console.error('Worker error:', response.error);
this.fillBlack();
break;
}
}
}
private fillBlack(): void {
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
private compile(): void {
this.isCompiled = false;
const id = `compile_${++this.idCounter}`;
// Send compile message to all workers
this.workers.forEach((worker) => {
worker.postMessage({
id,
type: 'compile',
code: this.code,
} as WorkerMessage);
});
}
private executeRender(id: string): void {
if (!this.isCompiled || this.isRendering) {
return;
}
this.isRendering = true;
// this._currentRenderID = id; // Removed unused property
const currentTime = (Date.now() - this.startTime) / TIMING.MILLISECONDS_PER_SECOND * this.timeSpeed;
// Always use multiple workers if available
if (this.workerCount > 1) {
this.renderWithMultipleWorkers(id, currentTime);
} else {
this.renderWithSingleWorker(id, currentTime);
}
}
private updateReusableMessage(id: string, currentTime: number, width: number, height: number, fullWidth: number, fullHeight: number, startY: number = 0): void {
this.reusableMessage.id = id;
this.reusableMessage.type = 'render';
this.reusableMessage.width = width;
this.reusableMessage.height = height;
this.reusableMessage.fullWidth = fullWidth;
this.reusableMessage.fullHeight = fullHeight;
this.reusableMessage.time = currentTime;
this.reusableMessage.renderMode = this.renderMode;
this.reusableMessage.valueMode = this.valueMode;
this.reusableMessage.hueShift = this.hueShift;
this.reusableMessage.startY = startY;
this.reusableMessage.mouseX = this.mouseX;
this.reusableMessage.mouseY = this.mouseY;
this.reusableMessage.mousePressed = this.mousePressed;
this.reusableMessage.mouseVX = this.mouseVX;
this.reusableMessage.mouseVY = this.mouseVY;
this.reusableMessage.mouseClickTime = this.mouseClickTime;
this.reusableMessage.touchCount = this.touchCount;
this.reusableMessage.touch0X = this.touch0X;
this.reusableMessage.touch0Y = this.touch0Y;
this.reusableMessage.touch1X = this.touch1X;
this.reusableMessage.touch1Y = this.touch1Y;
this.reusableMessage.pinchScale = this.pinchScale;
this.reusableMessage.pinchRotation = this.pinchRotation;
this.reusableMessage.accelX = this.accelX;
this.reusableMessage.accelY = this.accelY;
this.reusableMessage.accelZ = this.accelZ;
this.reusableMessage.gyroX = this.gyroX;
this.reusableMessage.gyroY = this.gyroY;
this.reusableMessage.gyroZ = this.gyroZ;
this.reusableMessage.audioLevel = this.audioLevel;
this.reusableMessage.bassLevel = this.bassLevel;
this.reusableMessage.midLevel = this.midLevel;
this.reusableMessage.trebleLevel = this.trebleLevel;
this.reusableMessage.bpm = this.currentBPM;
}
private renderWithSingleWorker(id: string, currentTime: number): void {
this.updateReusableMessage(id, currentTime, this.canvas.width, this.canvas.height, this.canvas.width, this.canvas.height, 0);
this.worker.postMessage(this.reusableMessage);
}
private renderWithMultipleWorkers(id: string, currentTime: number): void {
// Reset tile tracking
this.tileResults.clear();
this.tilesCompleted = 0;
this.totalTiles = this.workerCount;
const width = this.canvas.width;
const height = this.canvas.height;
const tileHeight = Math.ceil(height / this.workerCount);
// Distribute tiles to workers
this.workers.forEach((worker, index) => {
const startY = index * tileHeight;
const endY = Math.min((index + 1) * tileHeight, height);
if (startY >= height) return; // Skip if tile is outside canvas
// Update reusable message with worker-specific values
this.updateReusableMessage(
`${id}_tile_${index}`,
currentTime,
width,
endY - startY,
width,
height,
startY
);
worker.postMessage(this.reusableMessage);
});
}
setCode(code: string): void {
this.code = code;
this.compile();
}
render(animate: boolean = false): void {
const currentTime = performance.now();
// Frame rate limiting
if (animate && currentTime - this.lastFrameTime < this.frameInterval) {
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
return;
}
this.lastFrameTime = currentTime;
if (!this.isCompiled) {
this.fillBlack();
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
return;
}
const renderId = `render_${++this.idCounter}`;
// Add to pending renders queue
this.pendingRenders.push(renderId);
// If not currently rendering, start immediately
if (!this.isRendering) {
this.executeRender(renderId);
}
// Continue animation
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
}
startAnimation(): void {
this.stopAnimation();
this.startTime = Date.now();
this.lastFrameTime = 0; // Reset frame timing
this.render(true);
}
stopAnimation(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
// Clear pending renders
this.pendingRenders = [];
}
setTargetFPS(fps: number): void {
this.targetFPS = Math.max(TIMING.MIN_FPS, Math.min(TIMING.MAX_FPS, fps)); // Clamp between 1-120 FPS
this.frameInterval = TIMING.MILLISECONDS_PER_SECOND / this.targetFPS;
}
setRenderMode(mode: string): void {
this.renderMode = mode;
}
setValueMode(mode: string): void {
this.valueMode = mode;
}
setHueShift(shift: number): void {
this.hueShift = shift;
}
setTimeSpeed(speed: number): void {
this.timeSpeed = speed;
}
setBPM(bpm: number): void {
this.currentBPM = bpm;
}
setMousePosition(
x: number,
y: number,
pressed: boolean = false,
vx: number = 0,
vy: number = 0,
clickTime: number = 0
): void {
this.mouseX = x;
this.mouseY = y;
this.mousePressed = pressed;
this.mouseVX = vx;
this.mouseVY = vy;
this.mouseClickTime = clickTime;
}
setTouchPosition(
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;
}
setDeviceMotion(
ax: number,
ay: number,
az: number,
gx: number,
gy: number,
gz: number
): void {
this.accelX = ax;
this.accelY = ay;
this.accelZ = az;
this.gyroX = gx;
this.gyroY = gy;
this.gyroZ = gz;
}
setAudioData(level: number, bass: number, mid: number, treble: number): void {
this.audioLevel = level;
this.bassLevel = bass;
this.midLevel = mid;
this.trebleLevel = treble;
}
destroy(): void {
this.stopAnimation();
this.workers.forEach((worker) => worker.terminate());
}
private handleTileResult(
response: WorkerResponse,
workerIndex: number
): void {
if (!response.success || !response.imageData) {
console.error(
`Tile render failed for worker ${workerIndex}:`,
response.error
);
return;
}
// Store tile result
this.tileResults.set(workerIndex, response.imageData);
this.tilesCompleted++;
// Check if all tiles are complete
if (this.tilesCompleted === this.totalTiles) {
this.compositeTiles();
}
}
private async compositeTiles(): Promise<void> {
const height = this.canvas.height;
const tileHeight = Math.ceil(height / this.workerCount);
// Use ImageBitmap for faster compositing if available
if (typeof createImageBitmap !== 'undefined') {
try {
const bitmapPromises: Promise<ImageBitmap>[] = [];
const positions: number[] = [];
for (let i = 0; i < this.workerCount; i++) {
const tileData = this.tileResults.get(i);
if (tileData) {
bitmapPromises.push(createImageBitmap(tileData));
positions.push(i * tileHeight);
}
}
const bitmaps = await Promise.all(bitmapPromises);
for (let i = 0; i < bitmaps.length; i++) {
this.ctx.drawImage(bitmaps[i], 0, positions[i]);
bitmaps[i].close(); // Free memory
}
} catch (error) {
// Fallback to putImageData if ImageBitmap fails
this.fallbackCompositeTiles();
}
} else {
this.fallbackCompositeTiles();
}
// Clear tile results
this.tileResults.clear();
// Mark rendering as complete
this.isRendering = false;
// Process pending renders
if (this.pendingRenders.length > 0) {
this.pendingRenders.shift();
if (this.pendingRenders.length > 0) {
const latestId = this.pendingRenders[this.pendingRenders.length - 1];
this.pendingRenders = [latestId];
this.executeRender(latestId);
}
}
}
private fallbackCompositeTiles(): void {
const tileHeight = Math.ceil(this.canvas.height / this.workerCount);
for (let i = 0; i < this.workerCount; i++) {
const tileData = this.tileResults.get(i);
if (tileData) {
const startY = i * tileHeight;
this.ctx.putImageData(tileData, 0, startY);
}
}
}
// Simplified method - kept for backward compatibility but always uses all cores
setMultiWorkerMode(_enabled: boolean, _workerCount?: number): void {
// Always use all available cores, ignore the enabled parameter
console.log(
`Multi-worker mode is always enabled, using ${this.workerCount} cores for maximum performance`
);
}
getWorkerCount(): number {
return this.workerCount;
}
static generateRandomCode(): string {
const presets = [
'x^y',
'x&y',
'x|y',
'a|d|r',
'x|n*t^b*(t % 1.0)',
'(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',
'a^d * [b, r**t][floor(t%2.0)]',
'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',
];
const vars = ['x', 'y', 't', 'i', 'a', 'd', 'n', 'r', 'u', 'v', 'd', 'b'];
const ops = ['^', '&', '|', '+', '-', '*', '%', '**', '%'];
const shifts = ['<<', '>>'];
const numbers: number[] = [];
const numCount = Math.floor(Math.random() * 20) + 10; // Generate 10-30 numbers
for (let i = 0; i < numCount; i++) {
numbers.push(Math.floor(Math.random() * 400))
}
const randomChoice = <T>(arr: T[]): T =>
arr[Math.floor(Math.random() * arr.length)];
const dynamicExpressions = [
() => `${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)}`,
() =>
`(${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)})%${randomChoice(numbers)}`,
() =>
`${randomChoice(vars)}${randomChoice(shifts)}${Math.floor(Math.random() * 8)}`,
() =>
`(${randomChoice(vars)}*${randomChoice(vars)})%${randomChoice(numbers)}`,
() => `${randomChoice(vars)}^${randomChoice(vars)}^${randomChoice(vars)}`,
];
if (Math.random() < 0.5) {
return randomChoice(presets);
} else {
return randomChoice(dynamicExpressions)();
}
}
}

281
src/RefactoredShader.ts Normal file
View 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',
];
}
}

View File

@ -1,307 +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;
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 = '';
constructor() {
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);
break;
}
} catch (error) {
this.postError(message.id, error instanceof Error ? error.message : 'Unknown error');
}
}
private compileShader(id: string, code: string): void {
if (code === this.lastCode && this.compiledFunction) {
this.postMessage({ id, type: 'compiled', success: true });
return;
}
try {
const safeCode = this.sanitizeCode(code);
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};
})();
`);
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, message: WorkerMessage): void {
if (!this.compiledFunction) {
this.postError(id, 'No compiled shader');
return;
}
const imageData = new ImageData(width, height);
const data = imageData.data;
const startTime = performance.now();
const maxRenderTime = 50; // 50ms max render time
try {
for (let y = 0; y < height; y++) {
// Check timeout every row
if (performance.now() - startTime > maxRenderTime) {
// Fill remaining pixels with black and break
for (let remainingY = y; remainingY < height; remainingY++) {
for (let remainingX = 0; remainingX < width; remainingX++) {
const i = (remainingY * width + remainingX) * 4;
data[i] = 0; // R
data[i + 1] = 0; // G
data[i + 2] = 0; // B
data[i + 3] = 255; // A
}
}
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; // R
data[i + 1] = g; // G
data[i + 2] = b; // B
data[i + 3] = 255; // A
} catch (error) {
data[i] = 0; // R
data[i + 1] = 0; // G
data[i + 2] = 0; // B
data[i + 3] = 255; // A
}
}
}
this.postMessage({ id, type: 'rendered', success: true, imageData });
} catch (error) {
this.postError(id, error instanceof Error ? error.message : 'Render failed');
}
}
private calculateColor(value: number, renderMode: string): [number, number, number] {
const absValue = Math.abs(value) % 256;
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();

View File

@ -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
View 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 />
</>
);
}

View 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>
);
}

View 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}>
&times;
</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 &quot;Enable Audio&quot; to activate microphone</p>
</div>
<div className="help-section">
<h4>Operators</h4>
<p>
<strong>^ & |</strong> - XOR, AND, OR
</p>
<p>
<strong>&lt;&lt; &gt;&gt;</strong> - Bit shift left/right
</p>
<p>
<strong>+ - * / %</strong> - Math operations
</p>
<p>
<strong>== != &lt; &gt;</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&lt;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&gt;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&amp;&amp;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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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
View 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>
);
}

View 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
View 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,
};
}

View 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');
});
}

View 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
View 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 };
}

View File

@ -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 });
}

View File

@ -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
View 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 />);

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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);
}
}

View 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
View 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';

View 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();
}
}

View 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);
}
}

View 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 },
];
}

View 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
};
}

View 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;
}

View 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';

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View File

@ -13,7 +13,8 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@ -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: {