wgsl-tooling-wg / wesl-spec

A portable and modular superset of WGSL
BSD 3-Clause "New" or "Revised" License
35 stars 3 forks source link

Design a basic npm packaging format #56

Open mighdoll opened 1 month ago

mighdoll commented 1 month ago

Let's see if we can come up with a basic npm packaging format for simple WESL libraries so that JavaScript and TypeScript MVP users can get a taste of using stateless WESL libraries.

The packaging format doesn't need to be stable for long term and libaries should be labeled with an unstable version. See #5.

Here's one possible approach:

See also packaging and earlier gdoc for related discussion.

mighdoll commented 2 weeks ago

There's a prototype implementation here.

Some questions:

stefnotch commented 2 weeks ago

My immediate reactions to the questions would be

* should we also include the original wgsl/wesl source files in the package for e.g. a vite bundler to use?

No, vite can just as well use the Javascript object that contains everything.

* how should we package libraries with multiple parts? multiple exports entries? multiple bundles?

I think it doesn't matter, since tree-shaking should work either way.

mighdoll commented 2 weeks ago
* how should we package libraries with multiple parts? multiple exports entries? multiple bundles?

I think it doesn't matter, since tree-shaking should work either way.

The linkers will keep unused shader code from WebGPU, I agree.

But w/o further changes to spit things up, I think the javascript bundle will include all the shaders from the library. At least in the case of runtime linking w/o a build time support through e.g. a vite plugin..

iwoplaza commented 1 day ago

Great initiative! This might have already been resolved, but I'll share my initial thoughts. Let me know in case I missed something.

creates a file that includes WESL files as strings

If we instead parse the source WESL files on the side of the library author, before publishing, we can:

Example of what a type-friendly format could look like

Transform a WGSL file at prepublish time:

// The style of imports can of-course change in WESL.
use example-wgsl-utility::{ Gradient };

pub fn red_to_blue_gradient() -> Gradient {
  var result: Gradient;
  result.from = vec3f(1., 0., 0.);
  result.to = vec3f(0., 0., 1.);
  return result;
}:

Into this:

// This lets the types propagate from deep in the
// dependency tree straight up to the library consumer
import { Gradient } from 'example-wgsl-utility';

// Injecting only once, when one or more functions are defined
/**
 * @template {unknown[]} TArgs
 * @template TReturn
 * @typedef {object} WgslFn
 * @prop {'function'} kind
 * @prop {T} argTypes
 */
const fn = /**@type{<TArgs extends unknown[], TReturn>(argTypes: TArgs, returnType: TReturn, body: string)=>WgslFn<TArgs,TReturn>}*/ (
  (argTypes, returnType, body) => ({
    kind: 'function',
    argTypes,
    returnType,
    body,
  })
);

export const red_to_blue_gradient = fn([], Gradient, '() -> Gradient {
  var result: Gradient;
  result.from = vec3f(1., 0., 0.);
  result.to = vec3f(0., 0., 1.);
  return result;
}'};

Example: Using the library to retrieve just WGSL

import * as myLib from 'my-library';

const rawWGSL = linker.link(appWGSL, { libs: [myLib] });

Example: Using the library with TypeGPU

import { red_to_blue_gradient } from 'my-library';

const mainFrag = tgpu.fragmentFn({}, {}).does(() => {
  // inferred by TypeScript to return: WgslStruct<{ from: Vec3f, to: Vec3f }>
  const grad = red_to_blue_gradient();
  return vec4f(grad.from, 1.0);
});
stefnotch commented 1 day ago

One thing that this issue is still missing is "how does a language server get the .wesl code".

There are two major approaches

  1. Separate bundles for "language server wesl" and "consumer wesl".
  2. Combine them, and keep the format simple enough for a language server.
iwoplaza commented 1 day ago

If we'd like to support click to go to definition as well as IntelliSense, maybe we should ship the source .wesl/.wgsl files along with the consumer wesl. That way, when going to definition in host-land, it would take the developer to the consumer wesl, and when going to definition in wesl-land, it would take them to the source wesl.

iwoplaza commented 1 day ago

In the context of my proposal, the "consumer wesl" would be WGSL snippets that are wrapped in easy to interpret JS that can be consumed both by the TS type system as well as JS at runtime.

iwoplaza commented 1 day ago

On another note, how would transitive dependencies be handled in the current system? For the given example:

app-code
|- module_a
|  |- module_c
|- module_b

Lets say module_a has a dependency on module_c, both being WESL modules. Would the linker have to include all 3 when linking?:

linker.link(appWESL, { libs: [moduleA, moduleB, moduleC] });

If so, that means that if a module gains a direct dependency, then the module consumer has to also become a direct dependant of that dependency. If we instead leverage the host language's imports for "consumer wesl", then the transitive dependency problem solves itself.

mighdoll commented 16 hours ago

Lets say module_a has a dependency on module_c, both being WESL modules. Would the linker have to include all 3 when linking?:

Yes, currently. And also yes, it'd be great to fix things so that users didn't have to specify libraries manually when calling the linker. See vite plugin for WESL for some vague hopes that a plugin could help.

mighdoll commented 16 hours ago

If we instead parse the source WESL files on the side of the library author, before publishing, we can:

I think using a pre-parsed version of WGSL at runtime makes a lot of sense. Re-parsing to link at runtime takes time and code space. I expect that the parsing will eventually be more than 150K LOC/sec on a laptop, and that the parser part of the linker will be less than 10kb. I bet a pre-parsed version could save most of that time and code space.

If we had a build tool that converts project and library WESL to a pre-parsed form, the pre-parsed form would be more free to evolve and optimize. I think that might be wiser than trying to stabilize a pre-parsed form for long term support..

Or perhaps there ought to be optional fields in the library format. Packages could include WESL source as a baseline, but also include version locked pre-parsed WESL source, or shader-slang source, etc.

  • Use the host language's import functionality to perform linking for us.
  • Mirror the WESL file structure in the generated .js files, so that linking between WESL files can also happen automatically by just importing from a relative path.
  • Couple the metadata with the resources, instead of providing them in separate trees. That way, types can get properly inferred by libraries like TypeGPU, or any other TypeScript library.

I really like the ability to control linking from TypeScript, and also that exposing TypeScript types for WGSL elements so that interop between shaders and TypeScript is safer.

I think the interop problem is probably the easier one of the two, e.g. sharing types for uniforms by injected them from TypeScript or reflecting them from WGSL. I know you've been thinking about that too. Maybe we should start with that?

For linking control (i.e. controlling how shaders get assembled), I think some will prefer to control things from WESL and some will prefer to control things from TypeScript. Let's talk about how we can support both camps!

See #51 too.

  • Expose a minimal library to the library consumer that resolves the intermediate format into a spec-compliant WGSL code string.

I'm not sure if we can use WGSL as the pre-parsed form for shaders. It'd be nice! But wouldn't that preclude the WESL features like conditions or planned features like generics? Those features can trigger different WGSL code specialization at runtime. Maybe we could eventually design some kind of WGSL+annotations as the pre-parsed form..

iwoplaza commented 5 hours ago

If we had a build tool that converts project and library WESL to a pre-parsed form, the pre-parsed form would be more free to evolve and optimize. I think that might be wiser than trying to stabilize a pre-parsed form for long term support..

Definitely 🚀. Each host language would most likely have to have its own pre-parsed form that can be properly ingested. For TypeScript, I believe embedding types into packages at build-time, and publishing the result would be the only optimal solution. Otherwise we would be tasking ourselves with traversing the dependency tree recursively, and providing the types by means of code-gen, which is not the best DX. We'll still need codegen for the app WESL, but JS runtimes can differ in where they store libraries (Node: node_modules, Deno: a global cache).

iwoplaza commented 5 hours ago

I think the interop problem is probably the easier one of the two, ... Maybe we should start with that?

I'm working on a POC of that since yesterday, I'll share it once I have something to share 🙌

iwoplaza commented 5 hours ago

Let's talk about how we can support both camps!

From what I can understand, dynamic links (ones determined by the host code) are explicitly defined in WESL source code via @link statements, right? If so, we can use host language's imports for static linking, and leave the dynamic links for runtime linking.

mighdoll commented 5 hours ago

Let's talk about how we can support both camps!

From what I can understand, dynamic links (ones determined by the host code) are explicitly defined in WESL source code via @link statements, right? If so, we can use host language's imports for static linking, and leave the dynamic links for runtime linking.

The @link design sketch is just a sketch. The field is pretty open for design ideas on how this ought to work.

iwoplaza commented 5 hours ago

I'm not sure if we can use WGSL as the pre-parsed form for shaders

Let me clarify, the result of linking would be WGSL, the input (as in the app code and lib code) would be the pre-parsed format.

mighdoll commented 2 hours ago
flowchart LR
%% Nodes
    A(".wesl files")
    B("TypeScript Types")
    C("Preparsed Shaders")
    D("wgsl string")
    T>Type Tool]
    O>Opt Tool]
    P>Packager Tool]
    R>"App Bundler (opt)"]
    N[npm package]
    L>Linker]
    I[/IDE\]

%% Edge connections between nodes
    A --> R --> L --> D
    A --> P --> N --> R 
    A --> T --> B -.-> R
    A --> O --> C -.-> R 

%% styles
    classDef today fill:#7cbded;
    linkStyle default stroke-width:2;
    linkStyle 0,1,2,3,4,5 stroke:#7cbded,stroke-width:2;
    class A,D,P,N,L,R,I today;

Today we go from .wesl files directly to the linker (in blue).

I think we're discussing two new data structures. (They're both together in the sample you provide, but I'm separating them here for clarity.)

Many design questions that will be fun to work out together: