Skip to content

Add struct, a type of named members, as TSL's struct does - #359

Open
bigmistqke wants to merge 1 commit into
mainfrom
feat/struct
Open

bigmistqke wants to merge 1 commit into
mainfrom
feat/struct

Conversation

@bigmistqke

Copy link
Copy Markdown
Contributor

Problem

A GLSL or WGSL function returns one value. To return several, such as the bounds and the child index of a node of a bounding volume hierarchy read from a texture, a shader declares a struct and returns that. rmsl has no struct type, so a function with a layout (an Fn given { name, type, inputs }, which GLSL and WGSL emit as a function of its own) cannot return several values, and a shader written against TSL (the shading language of three.js) that uses TSL's struct does not port to rmsl by changing its import.

What changes

struct(members, name) makes a struct type and returns its constructor, as TSL's struct does:

import { Fn, int, ivec2, struct, textureLoad, uniform, vec4 } from "@random-mesh/rmsl";

const BVHNode = struct({ min: "vec3", max: "vec3", left: "int" }, "BVHNode");
const bvhTexture = uniform("sampler2D");

const getNode = Fn(([i]) => {
  const a = textureLoad(bvhTexture, ivec2(i.mul(2), int(0)));
  const b = textureLoad(bvhTexture, ivec2(i.mul(2).add(1), int(0)));
  return BVHNode(a.xyz, b.xyz, a.w.toInt());
}).setLayout({ name: "getNode", type: "BVHNode", inputs: [{ name: "i", type: "int" }] });

const colour = Fn(() => { // a fragment stage
  const node = getNode(int(3));
  return vec4(node.get("max"), node.get("left").toFloat());
});

GLSL declares the struct and the function, and calls it:

struct BVHNode {
  vec3 min;
  vec3 max;
  int left;
};

BVHNode getNode(int i) { … return BVHNode(…); }

void main(void) {
  BVHNode _rmsl_0 = getNode(3);
  _rmsl_fragColor = vec4(_rmsl_0.max, float(_rmsl_0.left));
}

WGSL declares struct BVHNode { min: vec3<f32>, max: vec3<f32>, left: i32 } and fn getNode(i: i32) -> BVHNode. JavaScript and WebAssembly have no struct type, so each member of a struct variable becomes a variable of its own: in the emitted JavaScript, toVar("node") gives node_min, node_max and node_left. A name taken twice gets a number appended, as for any variable.

What a struct does:

  • The constructor takes the members in order, BVHNode(min, max, left), or as one object, BVHNode({ min, max, left }). A member it is not given is zero, so BVHNode() is all zeros.
  • A number or an array of numbers given for a member is built as the constructor of the member's type builds it: in BVHNode(1, [0, 1, 2], 3), 1 gives vec3(1), [0, 1, 2] gives vec3(0, 1, 2) and 3 gives int(3).
  • value.get(name) reads a member. On a struct variable, made with toVar(), get(name).assign(x) writes that member, and assign(value) writes every member. A whole assignment reads its value in full before it writes a member, so v.assign(S(v.get("b"), v.get("a"))) swaps two members.
  • A member's type is a scalar, a vector, a matrix or the name of a struct made before, so structs nest: outer.get("inner").get("a").
  • A layout names a struct by its name, as its type or an input's type.
  • A struct given no name is named StructType and a number, such as StructType0. A graph deserialize loads gives such a struct a new number, so a graph saved in one process loads in another.
  • A struct name is one GLSL and WGSL can declare: no keyword, reserved word or built-in, and none of VertexInput, VertexOutput and FragmentOutput, which the WGSL target declares itself. A variable or an Fn with a layout named like a struct gets a number appended, so toVar("Light") for a Light struct declares Light1.
  • struct given a name again makes that struct again, so a module that a development server reloads redefines its structs, and a layout names the latest. A program that holds a value of each version is refused as it compiles. deserialize refuses a graph whose named struct has other members than the struct of that name in the loading program.
  • A member may take the name of a built-in function such as min or max. A member named like a keyword or a reserved word of GLSL or WGSL, such as type, or with the prefix gl_ or _rmsl_ or two underscores in a row, is refused.
  • serialize and deserialize carry the struct types a graph uses.
  • The TypeScript types: struct returns a StructConstructor, a value of it is a StructNode, and toVar() gives a StructVar, whose get returns a variable that assign writes. get of a name the struct lacks, and a value of another type for a member, are type errors. A member that is itself a struct is typed any: its type is given as a string, such as "Inner", which TypeScript cannot resolve back to that struct's members.

A variable, an input or an Fn with a layout named like a word WGSL reserves, such as type or target, is declared under a name WGSL can, type_, as one named like a keyword already is. WGSL refuses var type: f32, so a program with such a variable did not compile on WGSL; it now does.

A struct stays inside a program, the node graph a compile function is given:

  • equal, add, select and every other operation refuse a struct. Compare or combine the members.
  • A program whose result, the value it gives the host or the stage, is a struct is refused on every target. Return a member. An Fn inside the program may return a struct.
  • uniform, uniformArray, attribute, varying, attributeArray, instancedArray and storage refuse a struct type. Give each member an input of its own.
  • An assignment between a struct and a value of another type is refused.

Where rmsl departs from TSL

TSL code that rmsl refuses, with an error that names the cause:

  • get of a member the struct lacks. TSL logs a warning and reads 0.
  • A node of another type than its member, such as a float for a vec3 member or a float for an int member. TSL converts it. rmsl refuses mixed types here as it does for the operands of an operation, and the error names the conversion, such as toInt().
  • A program that holds values of two structs made under one name. TSL declares both structs under that name, which neither GLSL nor WGSL compiles. A ported shader with two structs of one name renames one.
  • equal of two structs. TSL writes it as ==, which GLSL defines for structs and WGSL does not, so the same program would compile on one target only.
  • A storage buffer of structs, which TSL supports. Should a struct be the element type of a storage buffer, as in TSL? #358 asks whether rmsl should, and how the JavaScript that fills and reads such a buffer would lay it out on each target.

TSL code that rmsl builds differently: an array of numbers given for a member, and a number given in the object form, BVHNode({ max: 4 }), are built as the member's type. TSL gives zero to such a member, so a ported shader that relied on that zero now reads the value it wrote.

Evidence

  • src/canon/struct.test.ts and src/canon/struct.test-d.ts: a test for each behaviour and refusal above. The tests that compute a value run on all four targets and assert that they agree: GLSL in WebGL 2 in a browser, WGSL on WebGPU through Dawn, JavaScript and WebAssembly. Every GLSL and WGSL shader the tests compile is checked by the browser's WebGL 2 compiler and by Dawn.
  • The TSL behaviour above comes from running three.js's code generator for GLSL and WGSL, on three.js 0.186.1 and on its dev branch.
  • CANON.md records each behaviour as a spec, each departure from TSL as an exception that cites the TSL fact it departs from, and those facts.
  • pnpm test: 3334 passed, 11 expected failures, 56 skipped. The expected failures are the tests marked to fail that pin known bugs already on main. None comes from this change. pnpm type-check, pnpm type-check:demos and pnpm test:types pass.

`struct(members, name)` makes a struct type and returns its constructor:

```ts
const BVHNode = struct({ min: "vec3", max: "vec3", left: "int" }, "BVHNode");
const getNode = Fn(([i]) => BVHNode(a.xyz, b.xyz, a.w.toInt())).setLayout({
  name: "getNode", type: "BVHNode", inputs: [{ name: "i", type: "int" }],
});
getNode(index).get("max");
```

The constructor takes the members in order or as one object by name, and
gives zero to each member it is not given. A number or an array of
numbers is built as the member's type. `get(name)` reads a member, an
`assign` on a member of a struct variable writes it, and an `assign` on
the variable writes every member. A member may be another struct, named
by its name, and a layout of an `Fn` names a struct as its return type or
an input's type. A struct given no name is named `StructType` and a
number. `serialize` and `deserialize` carry the struct types a graph uses.

GLSL and WGSL declare each struct once, a struct a member names ahead of
the struct that holds it, and call a function with a layout that takes or
returns one. JavaScript and WebAssembly run the body of such a function
where it is called, so no struct crosses a call there. A new pass after
the calls are written out splits each struct variable into a variable for
each member, and each member read into that variable. A whole assignment
from anything but another variable reads every member into a temporary
first, so `v.assign(S(v.get("b"), v.get("a")))` swaps the two members.

A struct stays inside a program. Every operation refuses one, a program
whose result is a struct is refused, an assignment between a struct and
another type is refused, and uniforms, attributes, varyings and storage
buffers refuse a struct type, where they are made and again in a graph
`deserialize` rebuilt.

rmsl departs from TSL where TSL builds a program no target runs or hides
a mistake. It refuses a member the struct lacks, which TSL reads as 0.
It refuses a node of another type than its member, which TSL converts,
as rmsl refuses mixed operands elsewhere. It refuses a program that holds
values of two structs made under one name: TSL declares both under the
one name, which neither GLSL nor WGSL compiles. It refuses `equal` of two structs, which TSL writes
as `==`, defined by GLSL and not by WGSL. It builds an array of numbers
given for a member, and a number given in the object form, as the
member's type, where TSL gives such a member zero. It refuses a storage
buffer of structs, which TSL takes; issue #358 asks whether rmsl should.

`struct` given a name again makes that struct again, and a layout names
the latest, so a module that a development server reloads redefines its
structs. `deserialize` gives a struct that was given no name a new
number, so a graph saved in one process loads in another, and refuses a
named struct whose members differ from the struct of that name in the
loading program.

A struct name is one GLSL and WGSL can declare, which excludes the
structs the WGSL target declares itself, and a variable or a function
named like a struct gets a number appended. A member may take the name
of a built-in function, such as `min` or `max`, as GLSL and WGSL allow:
the list of reserved names now keeps the keywords apart from the
built-ins, adds the words WGSL reserves, and a member name is checked
against those two lists. A variable, an input or a function with a
layout named like a word WGSL reserves, such as `type`, is declared under
a name WGSL can, `type_`, as one named like a keyword already is; WGSL
refuses `var type: f32`.

A TSL shader that groups values in a struct now ports by changing its
import, and a function with a layout can return several values, as a
GLSL or WGSL function does through a struct.

Canon units: spec-a-struct-groups-named-members-as-tsls-struct-does and
the specs and exceptions nested in it, spec-a-taken-function-name-gets-a-number, with the facts
fact-tsl-struct-makes-a-type-of-named-members,
fact-tsl-reads-a-member-a-struct-lacks-as-zero,
fact-tsl-converts-a-value-to-the-type-of-its-member,
fact-tsl-declares-two-structs-of-one-name,
fact-tsl-builds-an-array-or-a-named-number-for-a-member-as-zero,
fact-tsl-takes-a-struct-as-the-element-type-of-a-storage-buffer,
fact-tsl-writes-a-comparison-of-structs-with-the-operator and
fact-wgsl-compares-no-structs.

See #358
@pkg-pr-new

pkg-pr-new Bot commented Oct 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@random-mesh/rmsl@359

commit: 39d0fba

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant