Skip to content

Commit 73d69a0

Browse files
authored
feat: add live TSL previews across detail pages and playground (#65)
1 parent 84736d4 commit 73d69a0

16 files changed

Lines changed: 479 additions & 56 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
2+
import type {
3+
TslPreviewModuleResult,
4+
TslPreviewModuleRuntime,
5+
} from '../../../../packages/schema/src/tsl-preview-module.ts'
6+
7+
type THREE = typeof import('three/webgpu')
8+
type TSL = typeof import('three/tsl')
9+
10+
type TslPreviewCanvasProps = {
11+
previewModule: string
12+
pipeline: string
13+
fallbackSvg?: string | null
14+
onError?: (errors: string[]) => void
15+
onScreenshotReady?: (base64: string) => void
16+
}
17+
18+
type LoadedRuntime = {
19+
THREE: THREE
20+
TSL: TSL
21+
}
22+
23+
type PreviewInstance = TslPreviewModuleResult & {
24+
material: InstanceType<THREE['Material']>
25+
}
26+
27+
type PreviewModuleNamespace = {
28+
createPreview: (runtime: TslPreviewModuleRuntime) => TslPreviewModuleResult
29+
}
30+
31+
function defaultGeometry(THREE: THREE, pipeline: string) {
32+
if (pipeline === 'postprocessing') {
33+
return new THREE.PlaneGeometry(2, 2)
34+
}
35+
36+
if (pipeline === 'geometry') {
37+
return new THREE.SphereGeometry(1, 32, 32)
38+
}
39+
40+
return new THREE.PlaneGeometry(2, 2, 1, 1)
41+
}
42+
43+
export default function TslPreviewCanvas(props: TslPreviewCanvasProps) {
44+
let containerRef!: HTMLDivElement
45+
let renderer: InstanceType<THREE['WebGPURenderer']> | null = null
46+
let scene: InstanceType<THREE['Scene']> | null = null
47+
let camera: InstanceType<THREE['Camera']> | null = null
48+
let mesh: InstanceType<THREE['Mesh']> | null = null
49+
let runtime: LoadedRuntime | null = null
50+
let previewInstance: PreviewInstance | null = null
51+
let animationId = 0
52+
let currentModuleUrl: string | null = null
53+
54+
const [loading, setLoading] = createSignal(true)
55+
const [error, setError] = createSignal('')
56+
57+
function setPreviewError(message: string) {
58+
setError(message)
59+
props.onError?.([message])
60+
}
61+
62+
function clearPreviewError() {
63+
setError('')
64+
props.onError?.([])
65+
}
66+
67+
function captureScreenshot() {
68+
if (!renderer || !props.onScreenshotReady) return
69+
70+
try {
71+
const base64 = renderer.domElement.toDataURL('image/png')
72+
props.onScreenshotReady(base64)
73+
} catch {
74+
// Ignore screenshot failures. The preview can still be useful without one.
75+
}
76+
}
77+
78+
function disposePreviewMesh() {
79+
if (previewInstance?.dispose) {
80+
previewInstance.dispose()
81+
}
82+
83+
previewInstance = null
84+
85+
if (mesh && scene) {
86+
scene.remove(mesh)
87+
mesh.geometry?.dispose()
88+
mesh.material?.dispose()
89+
}
90+
91+
mesh = null
92+
93+
if (currentModuleUrl) {
94+
URL.revokeObjectURL(currentModuleUrl)
95+
currentModuleUrl = null
96+
}
97+
}
98+
99+
async function renderPreview(previewModule: string) {
100+
if (!runtime || !renderer || !scene || !camera) return
101+
102+
disposePreviewMesh()
103+
104+
try {
105+
const width = containerRef.clientWidth
106+
const height = containerRef.clientHeight || 400
107+
const blob = new Blob([previewModule], { type: 'text/javascript' })
108+
currentModuleUrl = URL.createObjectURL(blob)
109+
110+
const module = (await import(/* @vite-ignore */ currentModuleUrl)) as PreviewModuleNamespace
111+
if (typeof module.createPreview !== 'function') {
112+
throw new Error('TSL preview modules must export createPreview(runtime).')
113+
}
114+
115+
const nextPreview = module.createPreview({
116+
THREE: runtime.THREE,
117+
TSL: runtime.TSL,
118+
width,
119+
height,
120+
pipeline: props.pipeline,
121+
})
122+
123+
if (!nextPreview?.material || typeof nextPreview.material !== 'object') {
124+
throw new Error('createPreview(runtime) must return an object with a material.')
125+
}
126+
127+
previewInstance = nextPreview as PreviewInstance
128+
129+
const geometry = (previewInstance.geometry as InstanceType<THREE['BufferGeometry']> | undefined)
130+
?? defaultGeometry(runtime.THREE, props.pipeline)
131+
132+
const nextCamera = previewInstance.camera as InstanceType<THREE['Camera']> | undefined
133+
if (nextCamera) {
134+
camera = nextCamera
135+
}
136+
137+
mesh = new runtime.THREE.Mesh(geometry, previewInstance.material)
138+
scene.add(mesh)
139+
renderer.render(scene, camera)
140+
clearPreviewError()
141+
captureScreenshot()
142+
} catch (previewError) {
143+
disposePreviewMesh()
144+
setPreviewError(
145+
previewError instanceof Error
146+
? previewError.message
147+
: 'Failed to build the TSL preview module.',
148+
)
149+
} finally {
150+
setLoading(false)
151+
}
152+
}
153+
154+
onMount(async () => {
155+
if (!('gpu' in navigator)) {
156+
setPreviewError('WebGPU is not available in this browser.')
157+
setLoading(false)
158+
return
159+
}
160+
161+
try {
162+
const [THREE, TSL] = await Promise.all([
163+
import('three/webgpu'),
164+
import('three/tsl'),
165+
])
166+
167+
const width = containerRef.clientWidth
168+
const height = containerRef.clientHeight || 400
169+
170+
renderer = new THREE.WebGPURenderer({
171+
antialias: true,
172+
alpha: true,
173+
powerPreference: 'high-performance',
174+
})
175+
await renderer.init()
176+
177+
renderer.setSize(width, height)
178+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
179+
containerRef.appendChild(renderer.domElement)
180+
renderer.domElement.style.display = 'block'
181+
renderer.domElement.style.width = '100%'
182+
renderer.domElement.style.height = '100%'
183+
renderer.domElement.style.borderRadius = '1rem'
184+
185+
scene = new THREE.Scene()
186+
camera = props.pipeline === 'postprocessing'
187+
? new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
188+
: new THREE.PerspectiveCamera(45, width / height, 0.1, 100)
189+
camera.position.z = props.pipeline === 'postprocessing' ? 1 : 3
190+
191+
runtime = { THREE, TSL }
192+
193+
const animate = () => {
194+
if (!renderer || !scene || !camera) return
195+
animationId = requestAnimationFrame(animate)
196+
197+
if (props.pipeline === 'geometry' && mesh) {
198+
const elapsed = performance.now() * 0.001
199+
mesh.rotation.y = elapsed * 0.3
200+
mesh.rotation.x = elapsed * 0.15
201+
}
202+
203+
previewInstance?.update?.(performance.now() * 0.001)
204+
renderer.render(scene, camera)
205+
}
206+
207+
const handleResize = () => {
208+
if (!renderer || !camera || !runtime) return
209+
const nextWidth = containerRef.clientWidth
210+
const nextHeight = containerRef.clientHeight || 400
211+
renderer.setSize(nextWidth, nextHeight)
212+
213+
if (camera instanceof runtime.THREE.PerspectiveCamera) {
214+
camera.aspect = nextWidth / nextHeight
215+
camera.updateProjectionMatrix()
216+
}
217+
}
218+
219+
window.addEventListener('resize', handleResize)
220+
onCleanup(() => {
221+
window.removeEventListener('resize', handleResize)
222+
if (animationId) cancelAnimationFrame(animationId)
223+
animationId = 0
224+
disposePreviewMesh()
225+
renderer?.domElement.remove()
226+
renderer?.dispose()
227+
renderer = null
228+
scene = null
229+
camera = null
230+
runtime = null
231+
})
232+
233+
animate()
234+
await renderPreview(props.previewModule)
235+
} catch (previewError) {
236+
setPreviewError(
237+
previewError instanceof Error
238+
? previewError.message
239+
: 'Failed to initialize the TSL preview runtime.',
240+
)
241+
setLoading(false)
242+
}
243+
})
244+
245+
createEffect(
246+
on(
247+
() => props.previewModule,
248+
async (previewModule) => {
249+
if (!runtime || !renderer) return
250+
setLoading(true)
251+
await renderPreview(previewModule)
252+
},
253+
{ defer: true },
254+
),
255+
)
256+
257+
return (
258+
<div
259+
ref={containerRef}
260+
class="relative aspect-square w-full overflow-hidden rounded-2xl border border-surface-card-border bg-surface-primary"
261+
>
262+
{loading() && (
263+
<div class="absolute inset-0 flex items-center justify-center">
264+
<div class="h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
265+
</div>
266+
)}
267+
{error() && (
268+
<div class="absolute inset-0 flex items-center justify-center p-4">
269+
{props.fallbackSvg ? (
270+
<div class="h-full w-full" innerHTML={props.fallbackSvg} />
271+
) : (
272+
<div class="text-center">
273+
<p class="text-sm font-medium text-danger">Preview unavailable</p>
274+
<p class="mt-1 text-xs text-text-muted">{error()}</p>
275+
</div>
276+
)}
277+
</div>
278+
)}
279+
</div>
280+
)
281+
}

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

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import { createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
1+
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
2+
import { buildTslPreviewModule } from '../../../../../packages/schema/src/tsl-preview-module.ts'
3+
import TslPreviewCanvas from '../TslPreviewCanvas'
24

35
type THREE = typeof import('three')
46

57
type PlaygroundCanvasProps = {
68
vertexSource: string
79
fragmentSource: string
10+
tslSource?: string
811
pipeline: string
912
language: 'glsl' | 'tsl'
1013
onError: (errors: string[]) => void
@@ -19,6 +22,28 @@ function buildDefaultUniforms(THREE: THREE) {
1922
}
2023

2124
export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
25+
const tslPreviewModule = createMemo(() => {
26+
if (props.language !== 'tsl' || !props.tslSource) return ''
27+
28+
try {
29+
return buildTslPreviewModule(props.tslSource)
30+
} catch (error) {
31+
props.onError([error instanceof Error ? error.message : 'Failed to build TSL preview module'])
32+
return ''
33+
}
34+
})
35+
36+
if (props.language === 'tsl') {
37+
return (
38+
<TslPreviewCanvas
39+
previewModule={tslPreviewModule()}
40+
pipeline={props.pipeline}
41+
onError={props.onError}
42+
onScreenshotReady={props.onScreenshotReady}
43+
/>
44+
)
45+
}
46+
2247
let containerRef!: HTMLDivElement
2348
let renderer: InstanceType<THREE['WebGLRenderer']> | null = null
2449
let material: InstanceType<THREE['ShaderMaterial']> | null = null
@@ -33,12 +58,6 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
3358
const [initError, setInitError] = createSignal('')
3459

3560
onMount(async () => {
36-
// TSL preview not yet implemented — show placeholder
37-
if (props.language === 'tsl') {
38-
setLoading(false)
39-
return
40-
}
41-
4261
let THREE: THREE
4362
try {
4463
THREE = await import('three')
@@ -254,7 +273,7 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
254273
on(
255274
() => [props.vertexSource, props.fragmentSource] as const,
256275
([vertex, fragment]) => {
257-
if (!threeModule || !renderer || props.language === 'tsl') return
276+
if (!threeModule || !renderer) return
258277
compileShader(threeModule, vertex, fragment)
259278
},
260279
{ defer: true },
@@ -276,16 +295,6 @@ export default function PlaygroundCanvas(props: PlaygroundCanvasProps) {
276295
<p class="text-sm text-danger">{initError()}</p>
277296
</div>
278297
)}
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-
)}
289298
</div>
290299
)
291300
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export default function PlaygroundLayout(props: PlaygroundLayoutProps) {
182182
<PlaygroundCanvas
183183
vertexSource={vertexSource()}
184184
fragmentSource={fragmentSource()}
185+
tslSource={tslSource()}
185186
pipeline={props.session.pipeline}
186187
language={props.session.language}
187188
onError={handleErrors}

0 commit comments

Comments
 (0)