Skip to content

Commit 221fca0

Browse files
committed
First working version
1 parent a5b3cad commit 221fca0

File tree

14 files changed

+1171
-0
lines changed

14 files changed

+1171
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist
2+
*.egg-info

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
First version.

LICENSE

Lines changed: 661 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Shader Workshop
2+
3+
This tool is a local HTTP/WebSocket server monitoring the specified shader
4+
fragment directory. It allows live coding fragment shaders with your preferred
5+
code editor, and having them rendered in your browser through WebGL2.
6+
7+
## Usage
8+
9+
```
10+
shader-workshop /path/to/fragment/shaders
11+
```
12+
13+
If unspecified, `shader-workshop` will use the examples directory.
14+
15+
## Fragment inputs and outputs
16+
17+
Every fragment gets the following uniforms as input:
18+
19+
- `float time`: the time in seconds
20+
- `vec2 resolution`: the canvas resolution in pixels
21+
22+
They must write on the `vec4 out_color` output to produce a color.
23+
24+
The compatibility is currently set to `300 es`.

pyproject.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[build-system]
2+
requires = ["setuptools"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "shader-workshop"
7+
version = "0.1.0"
8+
description = "A local shader development environment"
9+
authors = [{ name = "bµg", email = "u@pkh.me" }]
10+
requires-python = ">=3.11"
11+
dependencies = [
12+
"watchdog",
13+
"aiohttp",
14+
"asyncio",
15+
]
16+
keywords = ["shaders", "glsl", "webgl"]
17+
18+
[project.urls]
19+
Repository = "https://github.com/ubitux/ShaderWorkshop"
20+
Issues = "https://github.com/ubitux/ShaderWorkshop/issues"
21+
Changelog = "https://github.com/ubitux/ShaderWorkshop/blob/main/CHANGELOG.md"
22+
23+
[project.scripts]
24+
shader-workshop = "shader_workshop.server:main"
25+
26+
[tool.setuptools]
27+
packages = ["shader_workshop"]
28+
29+
[tool.setuptools.package-data]
30+
"shader_workshop" = ["www/*", "frag-examples/*.frag"]

shader_workshop/__init__.py

Whitespace-only changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Stolen from ShaderToy
2+
void main() {
3+
vec2 uv = gl_FragCoord.xy / resolution;
4+
vec3 col = 0.5 + 0.5 * sin(time + uv.xyx + vec3(0.0, 2.0, 4.0));
5+
out_color = vec4(col, 1.0);
6+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
const float zoom = 0.4;
2+
const int octaves = 5;
3+
const float LACUNARITY = 1.98;
4+
const float GAIN = 0.51;
5+
const float TAU = 6.283185307179586;
6+
7+
float u2f(uint x) { return float(x>>8U)*uintBitsToFloat(0x33800000U); }
8+
9+
uint hash(uint x) {
10+
x = (x ^ (x >> 16)) * 0x21f0aaadU;
11+
x = (x ^ (x >> 15)) * 0x735a2d97U;
12+
return x ^ (x >> 15);
13+
}
14+
uint hash(uvec2 x) { return hash(x.x ^ hash(x.y)); }
15+
16+
vec2 grad(ivec2 x) { // ivec2 lattice to random 2D unit vector (circle point)
17+
float angle = u2f(hash(uvec2(x))) * TAU;
18+
return vec2(cos(angle), sin(angle));
19+
}
20+
21+
float noise(vec2 p) {
22+
ivec2 i = ivec2(floor(p));
23+
vec2 f = fract(p);
24+
float v0 = dot(grad(i), f);
25+
float v1 = dot(grad(i + ivec2(1, 0)), f - vec2(1.0, 0.0));
26+
float v2 = dot(grad(i + ivec2(0, 1)), f - vec2(0.0, 1.0));
27+
float v3 = dot(grad(i + ivec2(1, 1)), f - vec2(1.0, 1.0));
28+
vec2 a = (((6.0*f-15.0)*f+10.0)*f*f*f);
29+
return mix(mix(v0,v1,a.x),mix(v2,v3,a.x),a.y);
30+
}
31+
32+
float fbm(vec2 p) {
33+
float sum = 0.0, amp = 1.0;
34+
for (int i = 0; i < octaves; i++) {
35+
sum += amp * noise(p);
36+
p *= LACUNARITY;
37+
amp *= GAIN;
38+
}
39+
return sum;
40+
}
41+
42+
void main() {
43+
float w = 2.0/resolution.y; // size of a pixel
44+
float freq = 1.0/zoom;
45+
46+
vec2 uv = gl_FragCoord.xy / resolution;
47+
vec2 p = (uv*2.0 - 1.0) * vec2(resolution.x / resolution.y, 1.0);
48+
float n = fbm(p*freq + time*freq*0.1);
49+
50+
out_color = vec4((vec3(n)+1.0)/2.0, 1.0);
51+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
float circle(vec2 p, float r) {
2+
return length(p) - r;
3+
}
4+
5+
float square(vec2 p, vec2 b) {
6+
vec2 d = abs(p)-b;
7+
return length(max(d,0.0)) + min(max(d.x,d.y),0.0);
8+
}
9+
10+
void main() {
11+
vec2 uv = gl_FragCoord.xy / resolution;
12+
vec2 p = (uv*2.0-1.0) * vec2(resolution.x / resolution.y, 1.0);
13+
const float r = 0.7;
14+
float sd0 = circle(p, r);
15+
float sd1 = square(p, vec2(r));
16+
float t = (sin(time*2.0)+1.0)/2.0;
17+
float sd = mix(sd0, sd1, t);
18+
float aa = clamp(.5-sd/fwidth(sd),0.0,1.0);
19+
vec3 col = mix(vec3(0.0), vec3(0.8), aa);
20+
out_color = vec4(col, 1.0);
21+
}

shader_workshop/server.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import asyncio
2+
import json
3+
import sys
4+
from dataclasses import dataclass
5+
from pathlib import Path
6+
from typing import Self
7+
8+
from aiohttp import WSMsgType, web
9+
from watchdog.events import (DirModifiedEvent, FileModifiedEvent,
10+
FileSystemEventHandler)
11+
from watchdog.observers import Observer
12+
13+
_STATIC_DIR = Path(__file__).resolve().parent / "www"
14+
_EXAMPLES_DIR = Path(__file__).resolve().parent / "frag-examples"
15+
_SHADER_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else _EXAMPLES_DIR).resolve()
16+
17+
18+
class AsyncFileSystemEventHandler(FileSystemEventHandler):
19+
"""Push the watchdog events into the specified queue"""
20+
21+
def __init__(self, push_queue: asyncio.Queue):
22+
self._queue = push_queue
23+
self._loop = asyncio.get_running_loop()
24+
25+
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent):
26+
asyncio.run_coroutine_threadsafe(self._queue.put(event), self._loop)
27+
28+
29+
class EventBroadcaster:
30+
"""Forward the content of 1 queue to N sub-queues"""
31+
32+
def __init__(self, src_queue: asyncio.Queue):
33+
self._src_queue = src_queue
34+
self.subscribers = set()
35+
36+
def subscribe(self, dst_queue: asyncio.Queue):
37+
self.subscribers.add(dst_queue)
38+
39+
def unsubscribe(self, dst_queue: asyncio.Queue):
40+
self.subscribers.discard(dst_queue)
41+
42+
async def start(self):
43+
while True:
44+
event = await self._src_queue.get()
45+
for dst_queue in self.subscribers:
46+
await dst_queue.put(event)
47+
48+
49+
@dataclass
50+
class State:
51+
"""One websocket user context state"""
52+
53+
frags: list[str]
54+
selected: str | None
55+
56+
@classmethod
57+
def new(cls) -> Self:
58+
return cls(frags=[], selected=None)
59+
60+
def select(self, id: str):
61+
if id not in self.frags:
62+
return
63+
self.selected = id
64+
65+
async def refresh(self, ws: web.WebSocketResponse):
66+
self.frags = sorted(f.name for f in _SHADER_DIR.glob("*.frag"))
67+
if self.selected not in self.frags:
68+
self.selected = None
69+
await self._send_list(ws)
70+
71+
async def _send_list(self, ws: web.WebSocketResponse):
72+
payload = dict(type="list", frags=self.frags)
73+
await self._send_payload(ws, payload)
74+
75+
async def send_reload(self, ws: web.WebSocketResponse):
76+
payload = dict(type="reload")
77+
await self._send_payload(ws, payload)
78+
79+
async def _send_payload(self, ws: web.WebSocketResponse, payload: dict):
80+
# print(f">> {payload}")
81+
await ws.send_json(payload)
82+
83+
84+
async def _ws_handler(request):
85+
ws = web.WebSocketResponse()
86+
await ws.prepare(request)
87+
88+
state = State.new()
89+
await state.refresh(ws)
90+
91+
fs_events_queue = asyncio.Queue()
92+
93+
# Process incoming websocket messages (client events)
94+
async def process_ws_events():
95+
async for msg in ws:
96+
if msg.type == WSMsgType.TEXT:
97+
payload = json.loads(msg.data)
98+
# print(f"<< {payload}")
99+
state.select(payload["pick"])
100+
elif msg.type == WSMsgType.ERROR:
101+
print(f"WebSocket error: {ws.exception()}")
102+
103+
# Process incoming queue messages (watchdog filesystem events)
104+
async def process_fs_events():
105+
request.app["broadcaster"].subscribe(fs_events_queue)
106+
while True:
107+
msg: DirModifiedEvent | FileModifiedEvent = await fs_events_queue.get()
108+
# print(msg)
109+
if msg.is_directory:
110+
# print(f"directory {msg.src_path} changed")
111+
await state.refresh(ws)
112+
elif Path(str(msg.src_path)).name == state.selected:
113+
# print(f"change detected in {state.selected}")
114+
await state.send_reload(ws)
115+
116+
# Process both sources in parallel
117+
try:
118+
await asyncio.gather(process_ws_events(), process_fs_events())
119+
except asyncio.CancelledError:
120+
pass
121+
finally:
122+
request.app["broadcaster"].unsubscribe(fs_events_queue)
123+
await ws.close()
124+
125+
return ws
126+
127+
128+
async def _index(_):
129+
return web.FileResponse(_STATIC_DIR / "index.html")
130+
131+
132+
async def _frag(request):
133+
fname = request.match_info["name"]
134+
return web.FileResponse(_SHADER_DIR / f"{fname}.frag")
135+
136+
137+
async def _init_app():
138+
app = web.Application()
139+
app.router.add_get("/", _index)
140+
app.router.add_static("/static", _STATIC_DIR)
141+
app.router.add_get("/frag/{name}.frag", _frag)
142+
app.router.add_get("/ws", _ws_handler)
143+
144+
print(f"Shader directory: {_SHADER_DIR}")
145+
146+
# Setup event broadcaster: 1 filewatcher for N websockets
147+
event_queue = asyncio.Queue()
148+
app["broadcaster"] = EventBroadcaster(event_queue)
149+
asyncio.create_task(app["broadcaster"].start())
150+
151+
# File watcher events handler that will feed the broadcaster
152+
event_handler = AsyncFileSystemEventHandler(event_queue)
153+
154+
# Spawn file system observer
155+
observer = Observer()
156+
observer.schedule(event_handler, path=_SHADER_DIR.as_posix(), recursive=False)
157+
observer.start()
158+
159+
async def on_cleanup(_):
160+
observer.stop()
161+
observer.join()
162+
163+
app.on_cleanup.append(on_cleanup)
164+
return app
165+
166+
167+
def main():
168+
web.run_app(_init_app(), host="localhost", port=8080)

0 commit comments

Comments
 (0)