Skip to content

Give a number the type of the place it is given, and write it as a literal - #361

Open
bigmistqke wants to merge 4 commits into
mainfrom
fix/integer-constructor-literals
Open

bigmistqke wants to merge 4 commits into
mainfrom
fix/integer-constructor-literals

Conversation

@bigmistqke

@bigmistqke bigmistqke commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A JavaScript number given where rmsl expects a node has no shader type of its own, and each place that takes one decided its type differently. With i an int uniform and v an int variable:

Place What a number became
Beside an operand, i.add(2), or assigned, v.assign(2) an integer literal of the operand's or target's type
A constructor of numbers alone, ivec2(3, 0) an integer literal
A constructor beside a node, ivec2(i, 0) a float, converted: ivec2(_rmsl_u0, 0.0) on GLSL, vec2<i32>(…, i32(0f)) on WGSL
An input of an Fn with a layout, next(3) for an int input a float, so the call was refused as an argument of another type
An index, element(2) an int literal for a vector or a matrix. A float for a uniform array or a storage buffer, converted: [int(2.0)] on GLSL, [i32(2f)] or [u32(2f)] on WGSL

The same number compiled to an integer literal in one place, a converted float in another, and was refused in a third.

Fixes #360.

What changes

A number takes the type of the place it is given: the operand beside it, the target of an assign, a constructor's component, the input of an Fn with a layout, and int for an index. It compiles to a literal of that type on every target. A fraction given for an integer is truncated toward zero, in every place, as rmsl already converts a float node, an integer value the host gives and a float index of a storage element.

const i = uniform("int");
const next = Fn(([n]) => n.add(1)).setLayout({ name: "next", type: "int", inputs: [{ name: "n", type: "int" }] });

ivec2(i, 0);                        // GLSL ivec2(_rmsl_u0, 0), WGSL vec2<i32>(…, 0i)
next(3);                            // GLSL next(3), WGSL next(3i)
uniformArray("vec4", 4).element(2); // GLSL _rmsl_u1[2], WGSL _rmsl_u1[2i]

A number for a vector input fills every component: for an Fn scale whose layout has a vec3 input and a uint input, scale(2, 3) passes vec3(2) and the uint 3. A number for a bool or matrix input is refused, as before.

Fractions and what is refused

A fraction for an integer is truncated toward zero wherever it is given: i.add(2.9) adds 2, next(2.9) passes 2, element(1.5) reads element 1, ivec2(i, 2.9) holds 2, and Loop(2.9, …) runs twice.

What no integer stands for is refused:

  • A number the type cannot hold: ivec2(i, 3e9), next(3e9), i.add(3e9).
  • A negative number for an unsigned type, uvec2(u, -1) or next with a uint input given -1, as before.
  • A negative index, a fraction above -1 included: element(-1), element(-0.5).

Each refusal comes where the program builds the node, with an error that names the number, and the type or the element call it was given to. To fix such a call, give a number inside the type's range.

Code that behaves differently

  • ivec2(i, 3e9) and uvec3(u, 1, 5e9), with u a uint uniform, compiled, and now throw.
  • i.add(2.9) beside an integer, and Loop(2.9, …) for an integer index, were refused, and now truncate the fraction.
  • element(-1) of a uniform array or a storage buffer compiled, and now throws. Of a vector it was already refused, as out of range.
  • next(3) and next(2.9) for an int input, and scale(2, 3) for a vec3 input, were refused and now compile.
  • GLSL and WGSL write a number as a literal where they wrote a converted float, so a snapshot of the generated code changes.

What TSL does

On TSL's dev branch, which three.js releases next, a plain number takes the type of the node beside it, and a number for a layout Fn's input takes the input's type. rmsl follows that, and departs from TSL in five places, each recorded in CANON.md as an exception:

  • A fraction for an integer is truncated. TSL's dev branch truncates it too, so this departs from three.js r186 only, which rounds it for an input, an index or a Loop bound and keeps it a float beside an operand.
  • A number outside an integer's range beside an operand is refused. On dev it changes the operation's type, so i.add(3e9) adds two floats.
  • A number outside the range beside a node in a constructor is refused. TSL converts the float, int( 3000000000.0 ), which GLSL leaves undefined and WGSL clamps, so the targets disagree.
  • A negative index is refused. TSL writes it as 0u and reads the first element.
  • A number index is an int, the type element gives an index node. TSL builds a uint, [ 2u ]. The value is the same for every index an array holds, so only the generated text differs.

Facts about TSL

Where three.js 0.186.1 and TSL's dev branch differ, CANON.md states each version as a fact of its own, such as fact-tsl-r186-rounds-a-number-it-builds-as-an-integer and fact-tsl-r187dev-truncates-a-number-it-builds-as-an-integer, each with permalinks to the lines that do it. The TSL-parity axiom now says that a behaviour TSL changed between versions gives a TSL shader no one meaning a port could keep, so parity alone does not decide it; the fraction and out-of-range exceptions cite the fact of each version for that reason. In 0.186.1 a number beside an operand is a float constant, so i.add(2) writes i + 2.0, and a number TSL builds as an integer, for an input or an index, is rounded: generateConst in 0.186.1 writes it with Math.round. On dev a number takes its neighbour's type and is truncated: generateConst on dev writes it with Math.trunc.

Evidence

  • CANON.md: spec-a-number-takes-the-type-its-place-asks-for states the rule. Its leaves are the operand specs, the constructor spec, and specs for a layout Fn's input and for an index, with the five exceptions beside them.
  • src/canon/targets.test.ts and src/canon/layout-fn.test.ts: a test for each leaf and exception. The tests of a compiled number check the GLSL and WGSL text. Those that compute a value run on JavaScript, WebAssembly, GLSL in WebGL 2 and WGSL on WebGPU, and fail unless all four give the same value. Every new test fails on main, except the test of an operand out of range, which pins a refusal main already made.
  • src/usage.test.ts: two tests asserted the run-time conversion of a uniform array's index, [int(2.0)] and [i32(…)], and now assert the literal.
  • pnpm test: 3314 passed, 11 expected failures, 56 skipped. The expected failures are the tests marked to fail that pin known bugs already on main. pnpm type-check, pnpm type-check:demos and pnpm test:types pass. A JS draw allocation test fails now and then under the load of the full suite, unrelated to this change, tracked in The JS draw allocation test fails under the load of the full suite and passes alone #363.

Follow-ups

An integer vector constructor given a number beside a node, as
`ivec2(i, 0)`, now writes the number as a literal of its component type:
`ivec2(_rmsl_u0, 0)` on GLSL, `vec2<i32>(…, 0i)` on WGSL, `a[1] = 0` on
JavaScript, and an integer constant on WebAssembly. It built the number as a float and
converted it: GLSL wrote `0.0` into the integer constructor, WGSL wrapped
it as `i32(0f)`, and JavaScript clamped and truncated the constant at run
time on every call. A constructor given only numbers already wrote
integer literals, so a number now compiles alike whether or not a node
stands beside it.

The number goes through the same check as a number in a constructor of
numbers alone, so `ivec2(i, 3e9)` is refused where it is written, as
`ivec2(3e9, 0)` already was, and `ivec2(i, 2.9)` truncates to 2. Beside a
node, a number outside the range used to compile, and each target
converted it to an integer in its own way.

TSL writes the conversion, `int( 0.0 )`, so this changes the text rmsl
writes, not a value the program computes.

Canon units:
spec-a-number-beside-a-node-in-an-integer-vector-compiles-to-an-integer-literal,
spec-a-number-outside-an-integer-type-is-refused-by-its-constructor.

Fixes #360
@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@361

commit: 0b5ed9a

… and an index too

A JavaScript number has no shader type of its own. The canon now states
one rule for it: wherever a program gives a number, it takes the type
that place asks for. That is the operand beside it, the target of an
`assign`, a constructor's component, the input of an `Fn` with a layout,
and `int` for an index. A number the type cannot hold is refused, and so
is a fraction for an integer type outside a constructor, which converts
it toward zero as converting a float node does.

Two places did not follow the rule:

- A number given for an input of an `Fn` with a layout was always a
  `float`, so `next(3)` for an `int` input was refused. It now passes the
  `int` 3, and a number for a vector input fills every component. A
  number for a `bool` or matrix input stays refused. A fraction
  or a number outside the range of an integer input is refused, with an
  error that names the input.
- A number given as an index compiled three ways. A vector's `element`
  truncated a fraction silently, and a uniform array's and a storage
  buffer's built it as a float the program converted at run time, as
  `[int(2.0)]` and `[i32(2f)]`. Every `element` now compiles a number to
  an `int` literal, `[2]` and `[2i]`, and refuses a fraction or a
  negative number where it is given.

Where rmsl departs from TSL, the canon records an exception: a fraction
for an integer is refused where TSL makes it whole or keeps it a float;
a number outside an integer's range is refused where TSL changes the
operation's type or converts the float; a negative index is refused
where TSL reads the first element; and a number index is an `int` where
TSL builds a `uint`.

The TSL facts behind the rule are corrected and cite the three.js
source as permalinks at the release commit and at the `dev` branch.
Where the two versions differ, each is a fact of its own: three.js
0.186.1 builds a number beside an operand as a `float` and rounds a
number it builds as an integer, and its `dev` branch gives a number the
type of the node beside it and truncates. The TSL axiom now says that a
behaviour TSL changed between versions gives a port no one meaning to
keep, so parity alone does not decide it, and the canon's preamble asks
every fact about TSL to cite its source as a permalink.

Canon units: spec-a-number-takes-the-type-its-place-asks-for,
spec-a-number-for-a-layout-fn-input-takes-the-input-type,
spec-a-fraction-or-a-number-out-of-range-for-an-integer-input-is-refused,
spec-a-number-index-is-an-int-literal,
spec-a-fraction-or-a-negative-number-as-an-index-is-refused,
spec-a-call-with-an-argument-of-the-wrong-type-is-refused,
spec-a-uniform-array-is-read-by-element,
exception-a-fraction-for-an-integer-is-refused-where-tsl-makes-it-whole,
exception-a-number-beyond-an-integer-range-is-refused-where-tsl-changes-the-type,
exception-a-number-index-is-an-int-where-tsl-builds-a-uint,
exception-a-negative-index-is-refused-where-tsl-writes-zero,
exception-a-number-beyond-an-integer-range-beside-a-node-in-a-constructor-is-refused,
axiom-a-tsl-shader-ports-by-changing-its-import,
fact-tsl-turns-a-number-into-a-constant-node,
fact-tsl-r186-builds-a-number-beside-an-operand-as-a-float,
fact-tsl-r187dev-gives-a-number-the-type-of-the-node-beside-it,
fact-tsl-builds-a-number-beside-a-node-in-a-constructor-as-a-float,
fact-tsl-r186-rounds-a-number-it-builds-as-an-integer,
fact-tsl-r187dev-truncates-a-number-it-builds-as-an-integer,
fact-tsl-builds-a-number-for-a-layout-fn-input-as-its-type,
fact-tsl-builds-an-index-as-a-uint.

See #360, #362
@bigmistqke bigmistqke changed the title Write a number beside a node in an integer vector as an integer literal Give a number the type of the place it is given, and write it as a literal Oct 21, 2026
A number with a fraction given where an integer is asked for is now
truncated toward zero in every place: beside an integer operand,
`i.add(2.9)` adds 2; for an integer input of an `Fn` with a layout,
`next(2.9)` passes 2; as an index, `element(1.5)` reads element 1; and
as a bound of an integer `Loop`, `Loop(2.9, …)` runs twice. Each of
these was refused, while a constructor truncated, so whether a fraction
was accepted depended on the kind of place it went.

rmsl already truncates every other float it turns into an integer: a
float node converted with `int()`, an integer value the host gives, and
a float index of a storage element. A number now follows the same rule.
TSL's `dev` branch, r187dev, truncates in each of these places too;
three.js r186 rounds instead, which the canon records as the departure.

A number the integer type cannot hold is still refused, as is a
negative number for an unsigned type and a negative index, a fraction
above -1 included: no integer stands for them.

Canon units: spec-a-number-takes-the-type-its-place-asks-for,
spec-a-fraction-beside-an-integer-truncates,
spec-a-fraction-for-an-integer-input-truncates,
spec-a-number-out-of-range-for-an-integer-input-is-refused,
spec-a-fraction-index-truncates, spec-a-negative-index-is-refused,
spec-loop-truncates-a-fraction-for-an-integer-bound,
exception-a-fraction-for-an-integer-truncates-where-tsl-r186-rounds.

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.

An integer vector constructor writes a number beside a node as a converted float, not an integer literal

1 participant