Skip to content

Commit da6a038

Browse files
devallibusclaude
andauthored
feat: TSL (Three Shading Language) support (#57)
* feat: add TSL (Three Shading Language) support across the stack Implements discriminated union on `language` field throughout schema, registry, CLI, MCP, and playground to support TSL shaders alongside GLSL. Schema (0.1.0 → 0.2.0): - Discriminated union: GLSL (files: vertex+fragment) / TSL (tslEntry) - Add `node-material` to compatibility material enum - Language defaults to "glsl" for backward compatibility Registry (0.1.0 → 0.2.0): - Index entries include `language` field - Bundles: GLSL (vertexSource+fragmentSource) / TSL (tslSource) - Builder handles both manifest shapes CLI: - `add` writes source.ts for TSL, vertex+fragment.glsl for GLSL - `search` gains `language` filter MCP: - Tools updated with language/tslSource parameters - search_shaders, create_playground, update_shader accept language Playground: - Session types: language, tslSource, structuredErrors fields - DB migration: shader_language, tsl_source, structured_errors_json - API routes handle TSL create/update/errors - UI: language-aware tabs (source.ts vs vertex/fragment.glsl) - Canvas: TSL placeholder (WebGPU preview in follow-up) Corpus: - First TSL shader: tsl-gradient-wave (animated gradient with NodeMaterial) - Existing GLSL shaders bumped to schemaVersion 0.2.0 Closes #56 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: align TSL review follow-ups --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 43b8ae6 commit da6a038

30 files changed

Lines changed: 1089 additions & 270 deletions

apps/web/src/components/playground/PlaygroundCanvas.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ type PlaygroundCanvasProps = {
66
vertexSource: string
77
fragmentSource: string
88
pipeline: string
9+
language: 'glsl' | 'tsl'
910
onError: (errors: string[]) => void
1011
onScreenshotReady: (base64: string) => void
1112
}
@@ -32,6 +33,12 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
3233
const [initError, setInitError] = createSignal('')
3334

3435
onMount(async () => {
36+
// TSL preview not yet implemented — show placeholder
37+
if (props.language === 'tsl') {
38+
setLoading(false)
39+
return
40+
}
41+
3542
let THREE: THREE
3643
try {
3744
THREE = await import('three')
@@ -247,7 +254,7 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
247254
on(
248255
() => [props.vertexSource, props.fragmentSource] as const,
249256
([vertex, fragment]) => {
250-
if (!threeModule || !renderer) return
257+
if (!threeModule || !renderer || props.language === 'tsl') return
251258
compileShader(threeModule, vertex, fragment)
252259
},
253260
{ defer: true },
@@ -269,6 +276,16 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
269276
<p class="text-sm text-danger">{initError()}</p>
270277
</div>
271278
)}
279+
{!loading() && props.language === 'tsl' && (
280+
<div class="absolute inset-0 flex items-center justify-center p-4">
281+
<div class="text-center">
282+
<p class="text-sm font-medium text-text-secondary">TSL Preview</p>
283+
<p class="mt-1 text-xs text-text-muted">
284+
WebGPU-based TSL preview coming soon.
285+
</p>
286+
</div>
287+
</div>
288+
)}
272289
</div>
273290
)
274291
}

apps/web/src/components/playground/PlaygroundLayout.tsx

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,15 @@ type PlaygroundLayoutProps = {
1010

1111
export default function PlaygroundLayout(props: PlaygroundLayoutProps) {
1212
const [activeTab, setActiveTab] = createSignal<'fragment' | 'vertex'>('fragment')
13-
const [vertexSource, setVertexSource] = createSignal(props.session.vertexSource)
14-
const [fragmentSource, setFragmentSource] = createSignal(props.session.fragmentSource)
13+
const [vertexSource, setVertexSource] = createSignal(
14+
props.session.language === 'glsl' ? props.session.vertexSource : '',
15+
)
16+
const [fragmentSource, setFragmentSource] = createSignal(
17+
props.session.language === 'glsl' ? props.session.fragmentSource : '',
18+
)
19+
const [tslSource, setTslSource] = createSignal(
20+
props.session.language === 'tsl' ? props.session.tslSource : '',
21+
)
1522
const [errors, setErrors] = createSignal<string[]>(props.session.compilationErrors)
1623

1724
let debounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -24,9 +31,18 @@ export default function PlaygroundLayout(props: PlaygroundLayoutProps) {
2431

2532
eventSource.addEventListener('shader_update', (e) => {
2633
try {
27-
const data = JSON.parse(e.data) as { vertexSource: string; fragmentSource: string }
28-
setVertexSource(data.vertexSource)
29-
setFragmentSource(data.fragmentSource)
34+
const data = JSON.parse(e.data) as {
35+
language: string
36+
vertexSource?: string
37+
fragmentSource?: string
38+
tslSource?: string
39+
}
40+
if (data.language === 'glsl') {
41+
if (data.vertexSource !== undefined) setVertexSource(data.vertexSource)
42+
if (data.fragmentSource !== undefined) setFragmentSource(data.fragmentSource)
43+
} else if (data.language === 'tsl') {
44+
if (data.tslSource !== undefined) setTslSource(data.tslSource)
45+
}
3046
} catch {
3147
// Ignore malformed events
3248
}
@@ -47,13 +63,14 @@ export default function PlaygroundLayout(props: PlaygroundLayoutProps) {
4763
})
4864

4965
function handleEditorChange(value: string) {
50-
if (activeTab() === 'fragment') {
66+
if (props.session.language === 'tsl') {
67+
setTslSource(value)
68+
} else if (activeTab() === 'fragment') {
5169
setFragmentSource(value)
5270
} else {
5371
setVertexSource(value)
5472
}
5573

56-
// Debounce manual edits before syncing to server
5774
if (debounceTimer) clearTimeout(debounceTimer)
5875
debounceTimer = setTimeout(() => {
5976
syncToServer()
@@ -62,13 +79,14 @@ export default function PlaygroundLayout(props: PlaygroundLayoutProps) {
6279

6380
async function syncToServer() {
6481
try {
82+
const body = props.session.language === 'tsl'
83+
? { tslSource: tslSource() }
84+
: { vertexSource: vertexSource(), fragmentSource: fragmentSource() }
85+
6586
await fetch(`/api/playground/${props.session.id}/update`, {
6687
method: 'POST',
6788
headers: { 'Content-Type': 'application/json' },
68-
body: JSON.stringify({
69-
vertexSource: vertexSource(),
70-
fragmentSource: fragmentSource(),
71-
}),
89+
body: JSON.stringify(body),
7290
})
7391
} catch {
7492
// Network error — will retry on next edit
@@ -93,34 +111,52 @@ export default function PlaygroundLayout(props: PlaygroundLayoutProps) {
93111
}).catch(() => {})
94112
}
95113

96-
const currentEditorValue = () => (activeTab() === 'fragment' ? fragmentSource() : vertexSource())
114+
const currentEditorValue = () => {
115+
if (props.session.language === 'tsl') return tslSource()
116+
return activeTab() === 'fragment' ? fragmentSource() : vertexSource()
117+
}
97118

98119
return (
99120
<div class="flex h-[calc(100vh-56px)] flex-col lg:flex-row">
100121
{/* Left panel: editor */}
101122
<div class="flex min-h-0 flex-1 flex-col border-r border-surface-card-border">
102123
{/* Tab bar */}
103124
<div class="flex border-b border-surface-card-border bg-surface-primary">
104-
<button
105-
class={`px-4 py-2 text-xs font-medium transition ${
106-
activeTab() === 'fragment'
107-
? 'border-b-2 border-accent text-text-primary'
108-
: 'text-text-muted hover:text-text-secondary'
109-
}`}
110-
onClick={() => setActiveTab('fragment')}
111-
>
112-
fragment.glsl
113-
</button>
114-
<button
115-
class={`px-4 py-2 text-xs font-medium transition ${
116-
activeTab() === 'vertex'
117-
? 'border-b-2 border-accent text-text-primary'
118-
: 'text-text-muted hover:text-text-secondary'
119-
}`}
120-
onClick={() => setActiveTab('vertex')}
121-
>
122-
vertex.glsl
123-
</button>
125+
{props.session.language === 'tsl' ? (
126+
<button
127+
class="border-b-2 border-accent px-4 py-2 text-xs font-medium text-text-primary"
128+
>
129+
source.ts
130+
</button>
131+
) : (
132+
<>
133+
<button
134+
class={`px-4 py-2 text-xs font-medium transition ${
135+
activeTab() === 'fragment'
136+
? 'border-b-2 border-accent text-text-primary'
137+
: 'text-text-muted hover:text-text-secondary'
138+
}`}
139+
onClick={() => setActiveTab('fragment')}
140+
>
141+
fragment.glsl
142+
</button>
143+
<button
144+
class={`px-4 py-2 text-xs font-medium transition ${
145+
activeTab() === 'vertex'
146+
? 'border-b-2 border-accent text-text-primary'
147+
: 'text-text-muted hover:text-text-secondary'
148+
}`}
149+
onClick={() => setActiveTab('vertex')}
150+
>
151+
vertex.glsl
152+
</button>
153+
</>
154+
)}
155+
<div class="ml-auto flex items-center px-3">
156+
<span class="rounded bg-surface-card px-1.5 py-0.5 text-[10px] font-medium uppercase text-text-muted">
157+
{props.session.language === 'tsl' ? 'TSL' : 'GLSL'}
158+
</span>
159+
</div>
124160
</div>
125161

126162
{/* Editor */}
@@ -147,6 +183,7 @@ export default function PlaygroundLayout(props: PlaygroundLayoutProps) {
147183
vertexSource={vertexSource()}
148184
fragmentSource={fragmentSource()}
149185
pipeline={props.session.pipeline}
186+
language={props.session.language}
150187
onError={handleErrors}
151188
onScreenshotReady={handleScreenshotReady}
152189
/>

apps/web/src/lib/playground-types.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,55 @@ export type SessionMetadata = {
1818
tags?: string[]
1919
}
2020

21-
export type PlaygroundSession = {
21+
// ---------------------------------------------------------------------------
22+
// Structured errors
23+
// ---------------------------------------------------------------------------
24+
25+
export type PlaygroundError =
26+
| { kind: 'glsl-compile'; message: string }
27+
| { kind: 'glsl-link'; message: string }
28+
| { kind: 'tsl-parse'; message: string }
29+
| { kind: 'tsl-runtime'; message: string }
30+
| { kind: 'tsl-material-build'; message: string }
31+
32+
// ---------------------------------------------------------------------------
33+
// Session types — discriminated union on language
34+
// ---------------------------------------------------------------------------
35+
36+
type PlaygroundSessionBase = {
2237
id: string
23-
vertexSource: string
24-
fragmentSource: string
2538
uniforms: UniformDefinition[]
2639
uniformValues: Record<string, unknown> | null
2740
pipeline: string
2841
compilationErrors: string[]
42+
structuredErrors: PlaygroundError[]
2943
screenshotBase64: string | null
3044
screenshotAt: string | null
3145
metadata: SessionMetadata | null
3246
createdAt: string
3347
updatedAt: string
3448
}
3549

50+
export type GlslPlaygroundSession = PlaygroundSessionBase & {
51+
language: 'glsl'
52+
vertexSource: string
53+
fragmentSource: string
54+
}
55+
56+
export type TslPlaygroundSession = PlaygroundSessionBase & {
57+
language: 'tsl'
58+
tslSource: string
59+
}
60+
61+
export type PlaygroundSession = GlslPlaygroundSession | TslPlaygroundSession
62+
3663
// ---------------------------------------------------------------------------
3764
// SSE event types
3865
// ---------------------------------------------------------------------------
3966

40-
export type ShaderUpdateEvent = {
41-
type: 'shader_update'
42-
vertexSource: string
43-
fragmentSource: string
44-
}
67+
export type ShaderUpdateEvent =
68+
| { type: 'shader_update'; language: 'glsl'; vertexSource: string; fragmentSource: string }
69+
| { type: 'shader_update'; language: 'tsl'; tslSource: string }
4570

4671
export type UniformUpdateEvent = {
4772
type: 'uniform_update'
@@ -54,28 +79,42 @@ export type PlaygroundSSEEvent = ShaderUpdateEvent | UniformUpdateEvent
5479
// API request / response types
5580
// ---------------------------------------------------------------------------
5681

57-
export type CreateSessionRequest = {
82+
export type CreateGlslSessionRequest = {
83+
language?: 'glsl'
5884
vertexSource?: string
5985
fragmentSource?: string
6086
uniforms?: UniformDefinition[]
6187
pipeline?: string
6288
}
6389

90+
export type CreateTslSessionRequest = {
91+
language: 'tsl'
92+
tslSource?: string
93+
uniforms?: UniformDefinition[]
94+
pipeline?: string
95+
}
96+
97+
export type CreateSessionRequest = CreateGlslSessionRequest | CreateTslSessionRequest
98+
6499
export type CreateSessionResponse = {
65100
sessionId: string
66101
url: string
102+
previewAvailable: boolean
67103
}
68104

69105
export type UpdateShaderRequest = {
70106
vertexSource?: string
71107
fragmentSource?: string
108+
tslSource?: string
72109
}
73110

74111
export type UpdateShaderResponse = {
75112
status: 'ok'
76113
compilationErrors: string[]
114+
structuredErrors: PlaygroundError[]
77115
screenshotBase64: string | null
78116
browserConnected: boolean
117+
previewAvailable: boolean
79118
}
80119

81120
export type ScreenshotRequest = {
@@ -84,4 +123,5 @@ export type ScreenshotRequest = {
84123

85124
export type ErrorsResponse = {
86125
errors: string[]
126+
structuredErrors: PlaygroundError[]
87127
}

0 commit comments

Comments
 (0)