Skip to content

Commit 935832c

Browse files
feat: resolve #include directives
1 parent 2509e49 commit 935832c

File tree

11 files changed

+287
-74
lines changed

11 files changed

+287
-74
lines changed

src/index.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ type StringFilter<Value = string | RegExp> =
1313
exclude?: Value | Array<Value>
1414
}
1515

16-
// TODO: How to support GLSL + WGSL compilation as an API? Filter to allow platform-specific code?
1716
export interface ViteSlangOptions {
1817
/**
1918
* The output {@link SlangCompileTarget} to transform Slang shaders to.

src/index.js

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import { transformWithEsbuild } from 'vite'
2+
import * as fs from 'node:fs'
3+
import * as path from 'node:path'
24
import slangModule from './slang-2025.15-wasm/slang-wasm.js'
35

4-
const SLANG_STAGES = {
5-
vertex: 1,
6-
fragment: 5,
7-
compute: 6,
8-
}
9-
106
/**
117
* Tests a Vite filter against a file id.
128
*
@@ -30,6 +26,14 @@ function testFilter(id, filter) {
3026
}
3127
}
3228

29+
const SLANG_STAGES = {
30+
vertex: 1,
31+
fragment: 5,
32+
compute: 6,
33+
}
34+
35+
const IMPORT_REGEX = /^\s*#include\s+"([^"]+)"/gm
36+
3337
/** @type {Promise<import('./slang-2025.15-wasm/slang-wasm.js').MainModule> | null} */
3438
let slangPromise = null
3539

@@ -82,9 +86,19 @@ function viteSlang(options) {
8286
throw new Error(`Unable to create Slang session for ${options.target} target. Please file an issue.`)
8387
}
8488

85-
// TODO: module stitching and/or non-standard preprocessor unfolding?
8689
/** @type {import('./slang-2025.15-wasm/slang-wasm.js').Module | null} */
87-
const module = session.loadModuleFromSource(code, 'shader', id)
90+
const module = session.loadModuleFromSource(
91+
// Resolve #include directives
92+
code.replaceAll(IMPORT_REGEX, (match, file) => {
93+
try {
94+
return fs.readFileSync(path.resolve(path.dirname(id), file), { encoding: 'utf8' })
95+
} catch {
96+
return match
97+
}
98+
}),
99+
'shader',
100+
id,
101+
)
88102

89103
// Surface compilation errors
90104
if (!module) {

tests/__snapshots__/index.test.ts.snap

Lines changed: 180 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,23 +161,195 @@ fn fmain(@builtin(position) position_0 : vec4<f32>) -> pixelOutput_0
161161
}
162162
`;
163163
164+
exports[`viteSlang > can resolve #include directives 1`] = `
165+
{
166+
"code": "struct SLANG_ParameterGroup_Globals_std140_0
167+
{
168+
@align(16) time_0 : f32,
169+
};
170+
171+
@binding(0) @group(0) var<uniform> Globals_0 : SLANG_ParameterGroup_Globals_std140_0;
172+
struct vertexOutput_0
173+
{
174+
@builtin(position) output_0 : vec4<f32>,
175+
};
176+
177+
@vertex
178+
fn vmain(@builtin(vertex_index) vertexIndex_0 : u32) -> vertexOutput_0
179+
{
180+
var _S1 : vertexOutput_0 = vertexOutput_0( vec4<f32>(vec2<f32>(f32((((vertexIndex_0 << (u32(1)))) & (u32(2)))), f32((vertexIndex_0 & (u32(2))))) * vec2<f32>(2.0f) - vec2<f32>(1.0f), 0.0f, 1.0f) );
181+
return _S1;
182+
}
183+
184+
struct pixelOutput_0
185+
{
186+
@location(0) output_1 : vec4<f32>,
187+
};
188+
189+
@fragment
190+
fn fmain(@builtin(position) position_0 : vec4<f32>) -> pixelOutput_0
191+
{
192+
var _S2 : pixelOutput_0 = pixelOutput_0( vec4<f32>(vec3<f32>(0.80000001192092896f, 0.69999998807907104f, 1.0f) + vec3<f32>(0.30000001192092896f) * cos(normalize(position_0.xy / vec2<f32>(position_0.w)).xyx + vec3<f32>(Globals_0.time_0)), 1.0f) );
193+
return _S2;
194+
}
195+
196+
",
197+
"reflection": {
198+
"entryPoints": [
199+
{
200+
"name": "vmain",
201+
"parameters": [
202+
{
203+
"name": "vertexIndex",
204+
"semanticName": "SV_VERTEXID",
205+
"type": {
206+
"kind": "scalar",
207+
"scalarType": "uint32",
208+
},
209+
},
210+
],
211+
"result": {
212+
"semanticName": "SV_POSITION",
213+
"type": {
214+
"elementCount": 4,
215+
"elementType": {
216+
"kind": "scalar",
217+
"scalarType": "float32",
218+
},
219+
"kind": "vector",
220+
},
221+
},
222+
"stage": "vertex",
223+
},
224+
{
225+
"name": "fmain",
226+
"parameters": [
227+
{
228+
"name": "position",
229+
"semanticName": "SV_POSITION",
230+
"type": {
231+
"elementCount": 4,
232+
"elementType": {
233+
"kind": "scalar",
234+
"scalarType": "float32",
235+
},
236+
"kind": "vector",
237+
},
238+
},
239+
],
240+
"result": {
241+
"binding": {
242+
"index": 0,
243+
"kind": "varyingOutput",
244+
},
245+
"semanticName": "SV_TARGET",
246+
"stage": "fragment",
247+
"type": {
248+
"elementCount": 4,
249+
"elementType": {
250+
"kind": "scalar",
251+
"scalarType": "float32",
252+
},
253+
"kind": "vector",
254+
},
255+
},
256+
"stage": "fragment",
257+
},
258+
],
259+
"parameters": [
260+
{
261+
"binding": {
262+
"index": 0,
263+
"kind": "descriptorTableSlot",
264+
},
265+
"name": "Globals",
266+
"type": {
267+
"containerVarLayout": {
268+
"binding": {
269+
"index": 0,
270+
"kind": "descriptorTableSlot",
271+
},
272+
},
273+
"elementType": {
274+
"fields": [
275+
{
276+
"binding": {
277+
"elementStride": 0,
278+
"kind": "uniform",
279+
"offset": 0,
280+
"size": 4,
281+
},
282+
"name": "time",
283+
"type": {
284+
"kind": "scalar",
285+
"scalarType": "float32",
286+
},
287+
},
288+
],
289+
"kind": "struct",
290+
},
291+
"elementVarLayout": {
292+
"binding": {
293+
"elementStride": 0,
294+
"kind": "uniform",
295+
"offset": 0,
296+
"size": 16,
297+
},
298+
"type": {
299+
"fields": [
300+
{
301+
"binding": {
302+
"elementStride": 0,
303+
"kind": "uniform",
304+
"offset": 0,
305+
"size": 4,
306+
},
307+
"name": "time",
308+
"type": {
309+
"kind": "scalar",
310+
"scalarType": "float32",
311+
},
312+
},
313+
],
314+
"kind": "struct",
315+
},
316+
},
317+
"kind": "constantBuffer",
318+
},
319+
},
320+
],
321+
},
322+
}
323+
`;
324+
164325
exports[`viteSlang > throws if entrypoints are not defined 1`] = `
165-
[Error: [31m[vite-slang] An entrypoint must be defined with a shader stage attribute! Try adding [shader("fragment")] before your entrypoint method.[39m
166-
file: [36mshader.slang[39m]
326+
"[31m[vite-slang] An entrypoint must be defined with a shader stage attribute! Try adding [shader("fragment")] before your entrypoint method.[39m
327+
"
167328
`;
168329
169330
exports[`viteSlang > throws on shader compilation error 1`] = `
170-
[Error: [vite-slang] USER error: shader.slang(5): error 30015: undefined identifier 'error'.
171-
error;
172-
^~~~~
331+
"[vite-slang] USER error: C:/Users/Cody Bennett/Documents/GitHub/vite-slang/tests/shaders/broken.slang(4): error 30015: undefined identifier 'error'.
332+
error;
333+
^~~~~
334+
(0): error 39999: import of module 'shader' failed because of a compilation error
335+
(0): fatal error 39999: compilation ceased
336+
abort compilation: (0): fatal error 39999: compilation ceased
337+

338+
"
339+
`;
340+
341+
exports[`viteSlang > throws on unresolved #include directive 1`] = `
342+
"[vite-slang] USER error: C:/Users/Cody Bennett/Documents/GitHub/vite-slang/tests/shaders/include-error.slang(4): error 15300: failed to find include file 'include-2.slang'
343+
#include "include-2.slang"
344+
^~~~~~~~~~~~~~~~~
173345
(0): error 39999: import of module 'shader' failed because of a compilation error
174346
(0): fatal error 39999: compilation ceased
175347
abort compilation: (0): fatal error 39999: compilation ceased
176348

177-
file: [36mshader.slang[39m]
349+
"
178350
`;
179351
180352
exports[`viteSlang > throws on unsupported target 1`] = `
181-
[Error: [31m[vite-slang] Unsupported Slang target: unsupported.[39m
182-
file: [36mshader.slang[39m]
353+
"[31m[vite-slang] Unsupported Slang target: unsupported.[39m
354+
"
183355
`;

tests/index.test.ts

Lines changed: 23 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,13 @@
1-
import { describe, it, expect } from 'vitest'
1+
import { describe, it, expect, assert } from 'vitest'
22
import { build } from 'vite'
33
import viteSlang, { ViteSlangOptions } from '../src/index.js'
44

55
async function transform(
6-
shader: string,
6+
input: string,
77
options?: ViteSlangOptions,
88
): Promise<{ code: string; reflection: SlangReflectionJSON }> {
99
const compiled = await build({
10-
plugins: [
11-
{
12-
name: 'virtual',
13-
enforce: 'pre',
14-
resolveId(source) {
15-
if (source === 'shader.slang') return 'shader.slang'
16-
return null
17-
},
18-
load(id) {
19-
if (id === 'shader.slang') return shader
20-
return null
21-
},
22-
},
23-
viteSlang(options),
24-
],
10+
plugins: [viteSlang(options)],
2511
logLevel: 'silent',
2612
build: {
2713
target: 'esnext',
@@ -30,10 +16,7 @@ async function transform(
3016
modulePreload: false,
3117
rollupOptions: {
3218
treeshake: false,
33-
input: 'shader.slang',
34-
output: {
35-
entryFileNames: '[name].js',
36-
},
19+
input: new URL(input, import.meta.url).href,
3720
},
3821
},
3922
})
@@ -42,61 +25,44 @@ async function transform(
4225
return new Function(`${compiled.output[0].code}\nreturn { reflection, code };`)()
4326
}
4427

45-
const triangleShader = /* slang */ `
46-
cbuffer Globals: register(b0, space0) {
47-
float time;
48-
};
49-
50-
[shader("vertex")]
51-
float4 vmain(uint vertexIndex: SV_VertexID): SV_Position {
52-
float2 uv = float2((vertexIndex << 1) & 2, vertexIndex & 2);
53-
return float4(uv * 2.0 - 1.0, 0.0, 1.0);
54-
}
55-
56-
[shader("fragment")]
57-
float4 fmain(float4 position: SV_Position): SV_Target {
58-
float2 coord = position.xy / position.w;
59-
float3 color = float3(0.8, 0.7, 1.0) + 0.3 * cos(normalize(coord).xyx + time);
60-
return float4(color, 1.0);
28+
async function expectError(fn: () => Promise<any>): Promise<void> {
29+
try {
30+
assert(false, `Promise resolved "${await fn()}" instead of rejecting`)
31+
} catch (error) {
32+
expect((error as Error).message.replaceAll(/^file:.+$/gm, '')).toMatchSnapshot()
6133
}
62-
`
63-
64-
const emptyShader = /* slang */ `
65-
void main() {
66-
//
67-
}
68-
`
69-
70-
const brokenShader = /* slang */ `
71-
[shader("compute")]
72-
[numthreads(1, 1, 1)]
73-
void main(uint2 dispatchThreadId: SV_DispatchThreadID) {
74-
error;
75-
}
76-
`
34+
}
7735

7836
describe('viteSlang', () => {
7937
// Ensure ambient types work correctly for IntelliSense
80-
import('./stub.slang') satisfies Promise<{
38+
import('./shaders/stub.slang') satisfies Promise<{
8139
default: string
8240
code: string
8341
reflection: SlangReflectionJSON
8442
}>
8543

8644
it('can compile to WGSL by default', async () => {
87-
expect(await transform(triangleShader)).toMatchSnapshot()
45+
expect(await transform('./shaders/triangle.slang')).toMatchSnapshot()
46+
})
47+
48+
it('can resolve #include directives', async () => {
49+
expect(await transform('./shaders/include-0.slang')).toMatchSnapshot()
50+
})
51+
52+
it('throws on unresolved #include directive', async () => {
53+
await expectError(() => transform('./shaders/include-error.slang'))
8854
})
8955

9056
it('throws on unsupported target', async () => {
9157
// @ts-expect-error
92-
await expect(transform(triangleShader, { target: 'unsupported' })).rejects.toThrowErrorMatchingSnapshot()
58+
await expectError(() => transform('./shaders/triangle.slang', { target: 'unsupported' }))
9359
})
9460

9561
it('throws if entrypoints are not defined', async () => {
96-
await expect(transform(emptyShader)).rejects.toThrowErrorMatchingSnapshot()
62+
await expectError(() => transform('./shaders/empty.slang'))
9763
})
9864

9965
it('throws on shader compilation error', async () => {
100-
await expect(transform(brokenShader)).rejects.toThrowErrorMatchingSnapshot()
66+
await expectError(() => transform('./shaders/broken.slang'))
10167
})
10268
})

tests/shaders/broken.slang

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[shader("compute")]
2+
[numthreads(1, 1, 1)]
3+
void main(uint2 dispatchThreadId: SV_DispatchThreadID) {
4+
error;
5+
}

tests/shaders/empty.slang

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
void main() {
2+
//
3+
}

0 commit comments

Comments
 (0)