@solidrt/core 0.0.39 → 0.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,8 +35,11 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
35
35
  ## Images and GPU
36
36
  - `image.tsx` - `createImage` (async value: fetch + decode + upload) read inside a `<Loading>` boundary and shown with `<texture>`.
37
37
  - `inline-image.tsx` - bytes already in memory: `decodeImage` + `createTexture` (both synchronous) show an image with no `<Loading>` boundary. The sync counterpart to `image.tsx`.
38
- - `gpu-shader.tsx` - a GLSL fragment shader rendered to a texture, animated by driving its `iTime` uniform declaratively through the `<texture params={{...}}>` prop.
38
+ - `gpu-shader.tsx` - a GLSL fragment shader rendered to a texture, animated by driving its `iTime` uniform declaratively through the `<texture params={{...}}>` prop. Shows both source dialects side by side: without a `#version` line the runtime injects the `vUV`/`iResolution`/`iTime`/`fragColor` preamble, while a source starting with `#version 300 es` is compiled exactly as written and names its own uniforms - which is what lets a shader written elsewhere run unchanged.
39
+ - `gpu-texture-blend.tsx` - compositing two shader targets as stacked `<texture>` layers with `blendMode` ("plus" additive over a base pass), the alternative to a third shader that samples both. Click toggles against `"source-over"` to show why. The tree-level counterpart to blending within one draw (`gpu-particles.tsx`); without `blend: "add"` a target's own draw runs with GL blending disabled.
39
40
  - `gpu-raw-program.tsx` - the raw shading layer: compileShader/linkProgram/createShaderTarget, one vertex stage shared by two programs, with and without the standard header.
41
+ - `gpu-pipeline.tsx` - `createPipeline`: a custom vertex+fragment pair drawing an interleaved vertex buffer (position and color attributes resolved by name), a depth-tested spinning cube whose `uTime` uniform is driven through `<texture params>` exactly like a fragment shader's.
42
+ - `gpu-particles.tsx` - an additive particle field: `topology: "points"` with `blend: "add"`, so overlapping gaussian splats (`gl_PointSize` from the vertex stage, `gl_PointCoord` falloff, premultiplied output) accumulate into a glowing volume instead of overwriting. The tints are typed vec3 uniforms filled from 3-number array params.
40
43
  - `window-shader.tsx` - the `shader` prop on `<window>`: the finished frame drawn through a raw-linked warp program before present, click to toggle between warp and identity.
41
44
  - `window-shader-history.tsx` - the window shader's frame history: `previous` binds last frame as uPrevious, drawn as a one-frame motion echo behind an orbiting square; click toggles the echo term.
42
45
 
@@ -47,4 +50,5 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
47
50
  - `svg.tsx` - `<svg src={...}>` draws a whole SVG *document string* (not HTML/JSX children); multi-color fills vs a `currentColor` icon recolored by the `color` prop. This is how to use existing icon libraries (Lucide, Heroicons, etc.) - hand their SVG source to `src`.
48
51
 
49
52
  ## Bundling assets
50
- - `binary-import.tsx` - `import bytes from "./file" with { type: "binary" }` inlines a file's bytes into the bundle as a `Uint8Array` (the bytes are in memory, so `inline-image.tsx` displays them with the synchronous `decodeImage` + `createTexture` path). `with { type: "text" }` works the same way for a string.
53
+ - `binary-import.tsx` - `import bytes from "./file" with { type: "binary" }` inlines a file's bytes into the bundle as a `Uint8Array` (the bytes are in memory, so `inline-image.tsx` displays them with the synchronous `decodeImage` + `createTexture` path).
54
+ - `text-import.tsx` - `with { type: "text" }`, the string counterpart: inlines a `.glsl` shader source (`wave.glsl`) into the bundle, available synchronously with no runtime read. Works on any extension; `.svg` needs no attribute and `.glsl`/`.vert`/`.frag` are declared as text modules already.
@@ -0,0 +1,96 @@
1
+ // An additive particle field: createPipeline with topology "points" and
2
+ // blend "add". Each vertex is one particle; the vertex stage sets
3
+ // gl_PointSize (honored across 4..64px) and the fragment stage shapes the
4
+ // splat from gl_PointCoord. With blend: "add" overlapping splats accumulate
5
+ // (glBlendFunc(ONE, ONE)) - order-independent, so the buffer needs no
6
+ // sorting - which is what turns discrete discs into a smooth glowing field.
7
+ // Without it a target's draw overwrites, and a point cloud can only thicken
8
+ // into scaly overlap.
9
+ //
10
+ // Additive output is premultiplied by construction: write vec4(color * a, a)
11
+ // and the target stays composite-correct in the tree. No depth buffer here -
12
+ // nothing occludes anything in a pure additive pass. A scene where opaque
13
+ // geometry should occlude the particles would add depth: true and pair the
14
+ // blended draw with depthWrite: false, explicitly - neither option implies
15
+ // the other.
16
+ //
17
+ // The tints are typed (vec3) uniforms driven from 3-number array params.
18
+ import { render, onFrame, createSignal } from "@solidrt/core"
19
+ import { createBuffer, createPipeline } from "@solidrt/core/gpu"
20
+
21
+ let VERTEX = `
22
+ in vec3 aPos;
23
+ in float aSeed;
24
+ out float vSeed;
25
+ uniform float uTime;
26
+
27
+ void main() {
28
+ float cy = cos(uTime * 0.4), sy = sin(uTime * 0.4);
29
+ vec3 p = vec3(cy * aPos.x - sy * aPos.z, aPos.y, sy * aPos.x + cy * aPos.z);
30
+ // Each particle breathes on its own phase.
31
+ p *= 1.0 + 0.15 * sin(uTime * 1.7 + aSeed * 40.0);
32
+ p.z += 2.2;
33
+
34
+ // Same perspective mapping as gpu-pipeline.tsx (near 1, far 10), clip y
35
+ // negated so camera-up displays up.
36
+ float f = 2.0;
37
+ gl_Position = vec4(p.x * f, -p.y * f, p.z * (11.0 / 9.0) - 20.0 / 9.0, p.z);
38
+ gl_PointSize = mix(10.0, 26.0, aSeed) / p.z;
39
+ vSeed = aSeed;
40
+ }
41
+ `
42
+
43
+ let FRAGMENT = `
44
+ in float vSeed;
45
+ uniform vec3 uTintA;
46
+ uniform vec3 uTintB;
47
+
48
+ void main() {
49
+ // Soft gaussian falloff over the point sprite; gl_PointCoord is 0..1
50
+ // across the splat.
51
+ vec2 d = gl_PointCoord - 0.5;
52
+ float a = exp(-dot(d, d) * 14.0) * 0.35;
53
+ vec3 tint = mix(uTintA, uTintB, vSeed);
54
+ fragColor = vec4(tint * a, a);
55
+ }
56
+ `
57
+
58
+ // Interleaved [pos vec3, seed f32]: points on a fibonacci sphere, so the
59
+ // field reads as a volume from every angle.
60
+ function particles(count: number): Float32Array {
61
+ let verts: number[] = []
62
+ let golden = Math.PI * (3.0 - Math.sqrt(5.0))
63
+ for (let i = 0; i < count; i++) {
64
+ let y = 1.0 - (2.0 * (i + 0.5)) / count
65
+ let r = Math.sqrt(1.0 - y * y)
66
+ let t = golden * i
67
+ let seed = (i * 0.61803399) % 1.0
68
+ verts.push(0.7 * r * Math.cos(t), 0.7 * y, 0.7 * r * Math.sin(t), seed)
69
+ }
70
+ return new Float32Array(verts)
71
+ }
72
+
73
+ function App() {
74
+ let bufferId = createBuffer(particles(1500))
75
+ let id = createPipeline(VERTEX, FRAGMENT, 512, 512, {
76
+ params: { uTime: 0, uTintA: [1.0, 0.45, 0.15], uTintB: [0.25, 0.5, 1.0] },
77
+ attributes: [
78
+ { name: "aPos", format: "vec3" },
79
+ { name: "aSeed", format: "f32" },
80
+ ],
81
+ buffer: bufferId,
82
+ topology: "points",
83
+ blend: "add",
84
+ clearColor: [0.02, 0.02, 0.05, 1],
85
+ })
86
+ let [time, setTime] = createSignal(0)
87
+ onFrame((tick) => setTime(tick / 1000))
88
+
89
+ return (
90
+ <window alignItems="center" justifyContent="center">
91
+ <texture src={id} params={{ uTime: time() }} width={420} height={420} />
92
+ </window>
93
+ )
94
+ }
95
+
96
+ render(() => <App />)
@@ -0,0 +1,76 @@
1
+ // The raw shading layer: compileShader compiles one stage from complete GLSL
2
+ // ES (nothing injected - the source declares its own #version, precision,
3
+ // varyings and uniforms), linkProgram links a vertex and a fragment stage
4
+ // into a program, and createShaderTarget builds a render target over it. One
5
+ // program can back many targets, and creating a target compiles nothing, so
6
+ // swapping precompiled programs is free of compilation. Stages can be
7
+ // destroyed right after linking; the program keeps its own compiled copies.
8
+ //
9
+ // A raw program carries its own vertex stage, so a fullscreen pass is a
10
+ // covering triangle from gl_VertexID with vertexCount: 3. Compare
11
+ // gpu-shader.tsx, where the fused createShader does all of this in one call
12
+ // with an injected preamble.
13
+ import { render, onFrame, createSignal } from "@solidrt/core"
14
+ import { compileShader, createShaderTarget, destroyShader, linkProgram } from "@solidrt/core/gpu"
15
+
16
+ let VERTEX = `#version 300 es
17
+ precision highp float;
18
+ out vec2 vUV;
19
+ void main() {
20
+ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
21
+ vUV = p;
22
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
23
+ }
24
+ `
25
+
26
+ // Two fragment stages linked against the same vertex stage: two programs,
27
+ // one shared compile of the vertex half.
28
+ let WAVES = `#version 300 es
29
+ precision highp float;
30
+ in vec2 vUV;
31
+ out vec4 fragColor;
32
+ uniform float iTime;
33
+ void main() {
34
+ float t = iTime * 2.0;
35
+ float a = 0.5 + 0.5 * sin(vUV.x * 10.0 + t);
36
+ float b = 0.5 + 0.5 * sin(vUV.y * 10.0 - t * 1.3);
37
+ fragColor = vec4(a, b, 1.0 - a * b, 1.0);
38
+ }
39
+ `
40
+
41
+ // The standard header ({ header: true }) declares #version, precision,
42
+ // iResolution/iTime and fragColor, so this source only adds its own inputs.
43
+ let RINGS = `
44
+ in vec2 vUV;
45
+ void main() {
46
+ float d = length(vUV - 0.5);
47
+ float r = 0.5 + 0.5 * sin(d * 40.0 - iTime * 3.0);
48
+ fragColor = vec4(r, r * 0.6, 1.0 - r, 1.0);
49
+ }
50
+ `
51
+
52
+ function App() {
53
+ let vs = compileShader("vertex", VERTEX)
54
+ let wavesFs = compileShader("fragment", WAVES)
55
+ let ringsFs = compileShader("fragment", RINGS, { header: true })
56
+ let waves = linkProgram(vs, wavesFs)
57
+ let rings = linkProgram(vs, ringsFs)
58
+ destroyShader(vs)
59
+ destroyShader(wavesFs)
60
+ destroyShader(ringsFs)
61
+
62
+ let wavesId = createShaderTarget(waves, 512, 512, { vertexCount: 3, params: { iTime: 0 } })
63
+ let ringsId = createShaderTarget(rings, 512, 512, { vertexCount: 3, params: { iTime: 0 } })
64
+
65
+ let [time, setTime] = createSignal(0)
66
+ onFrame(tick => setTime(tick / 1000))
67
+
68
+ return (
69
+ <window flexDirection="row" gap={16} alignItems="center" justifyContent="center">
70
+ <texture src={wavesId} params={{ iTime: time() }} width={360} height={360} />
71
+ <texture src={ringsId} params={{ iTime: time() }} width={360} height={360} />
72
+ </window>
73
+ )
74
+ }
75
+
76
+ render(() => <App />)
@@ -1,17 +1,32 @@
1
1
  // createShader compiles a GLSL ES 3.00 fragment shader and renders it into a
2
2
  // texture, returning a texture id you display with <texture src={id}>. The
3
- // fragment body may reference vUV (0..1, top-left origin), iResolution, iTime, and
4
- // any `uniform float` it declares; there is no #version line - the runtime injects
5
- // the preamble. The texture is freed automatically when the reactive owner is
6
- // disposed.
3
+ // texture is freed automatically when the reactive owner is disposed.
4
+ //
5
+ // There are two source dialects and the source itself picks which one applies.
6
+ // WITHOUT a #version line the runtime injects a preamble, so the body may
7
+ // reference vUV (0..1, top-left origin), iResolution, iTime, and any uniform
8
+ // it declares - the left square below. A source that STARTS
9
+ // with #version 300 es is taken as complete and compiled exactly as written:
10
+ // nothing is injected and it names its own uniforms - the right square.
11
+ //
12
+ // That second dialect is what lets a shader written for somewhere else run
13
+ // here unchanged, without dropping to the raw layer; see gpu-raw-program.tsx
14
+ // for what compileShader/linkProgram are actually for (sharing one compile
15
+ // across several targets). The built-in vertex stage supplies vUV either way -
16
+ // a complete source just has to declare `in vec2 vUV;` itself - and a uniform
17
+ // named iResolution is filled with the target size by name in both dialects.
7
18
  //
8
19
  // iResolution is filled in for you, but iTime is NOT - drive it (and any other
9
20
  // uniform) declaratively via the <texture> element's params prop; it applies at
10
21
  // the next repaint, so a signal updated every frame stays paced to actual frames.
11
- // The shader's size is baked in at creation.
22
+ // A param value is a number for a scalar uniform or a flat number array for a
23
+ // typed one (2/3/4 numbers for vec2/vec3/vec4, 16 column-major for mat4),
24
+ // dispatched by the shader's own declaration - uTint below is a vec3 driven
25
+ // from one array value. The shader's size is baked in at creation.
12
26
  import { render, onFrame, createSignal } from "@solidrt/core"
13
27
  import { createShader } from "@solidrt/core/gpu"
14
28
 
29
+ // Injected-preamble dialect: no #version, no declarations, no main() plumbing.
15
30
  let FRAGMENT = `
16
31
  void main() {
17
32
  vec2 uv = vUV;
@@ -23,16 +38,36 @@ void main() {
23
38
  }
24
39
  `
25
40
 
41
+ // Complete-source dialect: declares its own version, precision, varying and
42
+ // output, and calls its time uniform uSpin rather than iTime. uTint is a
43
+ // typed (vec3) uniform, filled from a 3-number array param.
44
+ let RAW_FRAGMENT = `#version 300 es
45
+ precision highp float;
46
+ in vec2 vUV;
47
+ out vec4 fragColor;
48
+ uniform float uSpin;
49
+ uniform vec3 uTint;
50
+ void main() {
51
+ vec2 p = vUV - 0.5;
52
+ float a = atan(p.y, p.x) + uSpin;
53
+ float r = length(p);
54
+ float band = 0.5 + 0.5 * sin(a * 6.0 + r * 18.0);
55
+ fragColor = vec4(band * uTint.r, band * uTint.g, 1.0 - band * uTint.b, 1.0);
56
+ }
57
+ `
58
+
26
59
  function App() {
27
60
  let id = createShader(FRAGMENT, 512, 512, { iTime: 0 })
61
+ let rawId = createShader(RAW_FRAGMENT, 512, 512, { uSpin: 0, uTint: [0.9, 0.4, 0.6] })
28
62
  let [time, setTime] = createSignal(0)
29
63
  onFrame((tick) => setTime(tick / 1000))
30
64
 
31
65
  return (
32
- <window alignItems="center" justifyContent="center">
33
- <texture src={id} params={{ iTime: time() }} width={400} height={400} />
66
+ <window alignItems="center" justifyContent="center" flexDirection="row" gap={16}>
67
+ <texture src={id} params={{ iTime: time() }} width={260} height={260} />
68
+ <texture src={rawId} params={{ uSpin: time() }} width={260} height={260} />
34
69
  </window>
35
70
  )
36
71
  }
37
72
 
38
- render(() => <App />)
73
+ render(() => <App />)
@@ -0,0 +1,78 @@
1
+ // Compositing two GPU passes in the element tree: stack <texture> layers and
2
+ // give the upper one a blendMode. This is how several shader targets combine -
3
+ // a base pass plus an additive pass - without writing a third shader that
4
+ // samples both. The full Skia blend set is available ("plus", "screen",
5
+ // "multiply", ...), and texture alpha is premultiplied, so additive modes need
6
+ // no manual premultiplication.
7
+ //
8
+ // It is also the ONLY blending there is. A target's own draw runs with GL
9
+ // blending disabled, so overlapping geometry inside one shader or pipeline
10
+ // overwrites rather than accumulates; splitting the work across targets and
11
+ // compositing them here is the way to get transparency between passes.
12
+ //
13
+ // Click to toggle the upper layer between "plus" and "source-over". The glow
14
+ // pass paints opaque black outside its ring, so source-over hides the base
15
+ // entirely while plus adds only the lit pixels - black contributes nothing.
16
+ import { render, onFrame, createSignal } from "@solidrt/core"
17
+ import { createShader } from "@solidrt/core/gpu"
18
+
19
+ let SIZE = 360
20
+
21
+ // Base pass: a static gradient with a soft vignette. Nothing drives it, so it
22
+ // renders once at creation and then holds - shaders re-render on params writes.
23
+ let BASE = `
24
+ void main() {
25
+ vec2 uv = vUV;
26
+ float v = 1.0 - length(uv - 0.5) * 1.1;
27
+ vec3 col = mix(vec3(0.04, 0.05, 0.14), vec3(0.15, 0.10, 0.42), uv.y);
28
+ fragColor = vec4(col * v, 1.0);
29
+ }
30
+ `
31
+
32
+ // Additive pass: a breathing ring, black everywhere else.
33
+ let GLOW = `
34
+ void main() {
35
+ vec2 uv = vUV;
36
+ float r = length(uv - 0.5);
37
+ float radius = 0.28 + 0.04 * sin(iTime * 2.0);
38
+ float ring = smoothstep(0.06, 0.0, abs(r - radius));
39
+ fragColor = vec4(vec3(1.0, 0.55, 0.15) * ring, 1.0);
40
+ }
41
+ `
42
+
43
+ function App() {
44
+ let baseId = createShader(BASE, SIZE, SIZE)
45
+ let glowId = createShader(GLOW, SIZE, SIZE, { iTime: 0 })
46
+ let [time, setTime] = createSignal(0)
47
+ let [mode, setMode] = createSignal<"plus" | "source-over">("plus")
48
+ onFrame((tick) => setTime(tick / 1000))
49
+
50
+ return (
51
+ <window
52
+ onPointerDown={() => setMode((m) => (m === "plus" ? "source-over" : "plus"))}
53
+ flexDirection="column"
54
+ alignItems="center"
55
+ justifyContent="center"
56
+ gap={12}
57
+ >
58
+ {/* The layers resolve against this box: absolute children need an
59
+ ancestor with position "relative". */}
60
+ <view position="relative" width={SIZE} height={SIZE}>
61
+ <texture src={baseId} position="absolute" top={0} left={0} width={SIZE} height={SIZE} />
62
+ <texture
63
+ src={glowId}
64
+ position="absolute"
65
+ top={0}
66
+ left={0}
67
+ width={SIZE}
68
+ height={SIZE}
69
+ blendMode={mode()}
70
+ params={{ iTime: time() }}
71
+ />
72
+ </view>
73
+ <text fontSize={14} color="#888">blendMode "{mode()}" - click to toggle</text>
74
+ </window>
75
+ )
76
+ }
77
+
78
+ render(() => <App />)
@@ -0,0 +1,29 @@
1
+ // Importing a file with `with { type: "text" }` inlines its contents into the
2
+ // bundle as a string. Like the binary form, the text travels inside the
3
+ // compiled bytecode, so it is available synchronously - no runtime read, works
4
+ // offline. The attribute works on any extension; `.svg` is text-loaded without
5
+ // one, and `.glsl`/`.vert`/`.frag` are declared as text modules so shader
6
+ // sources typecheck with no setup.
7
+ //
8
+ // This example shows only the text import itself: it reports the imported
9
+ // file's size and first line. Shader sources are the motivating case - the
10
+ // string is exactly what gpu-shader.tsx passes to createShader, moved out of
11
+ // the .tsx so it can be edited as GLSL. Inlining trades update granularity for
12
+ // zero I/O, so keep big or streamable files in assets/ and read them at
13
+ // runtime instead.
14
+ import { render } from "@solidrt/core"
15
+ import source from "./wave.glsl" with { type: "text" }
16
+
17
+ // The file's own first line - proof the real text is inlined, not a path.
18
+ let firstLine = source.split("\n")[0] ?? ""
19
+
20
+ function App() {
21
+ return (
22
+ <window alignItems="center" justifyContent="center" gap={8}>
23
+ <text fontSize={18} color="#e6e6e6">{source.length} characters inlined</text>
24
+ <text color="#888">starts with {firstLine}</text>
25
+ </window>
26
+ )
27
+ }
28
+
29
+ render(() => <App />)
@@ -0,0 +1,13 @@
1
+ // wave.glsl - animated scanline
2
+ //
3
+ // A plain fragment body in the injected-preamble dialect: no #version line, so
4
+ // createShader prepends vUV / iResolution / iTime / fragColor. Living in its
5
+ // own file it stays editable as GLSL instead of as a template literal.
6
+ void main() {
7
+ vec2 uv = vUV;
8
+ float wave = sin(uv.x * 12.0 + iTime * 2.0) * 0.06;
9
+ float d = abs(uv.y - 0.5 - wave);
10
+ float line = smoothstep(0.05, 0.0, d);
11
+ vec3 col = mix(vec3(0.05, 0.07, 0.12), vec3(0.2, 0.8, 1.0), line);
12
+ fragColor = vec4(col, 1.0);
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "colord": "^2.9.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@solidrt/flux-types": "0.0.39"
30
+ "@solidrt/flux-types": "0.0.40"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@solidjs/signals": "2.0.0-beta.26",
package/src/gpu.ts CHANGED
@@ -7,6 +7,20 @@
7
7
  // to hold a params prop, e.g. a shader that only feeds another shader as a
8
8
  // sampler2D input. The imperative primitives (uploadTexture, setShaderParams,
9
9
  // destroyTexture, ...) live in the `flux:gpu` module.
10
+ //
11
+ // Sampling is a per-texture property declared at creation: `filter`
12
+ // ("linear" default | "nearest") and `wrap` ("clamp" default | "repeat") on
13
+ // every create* helper. One state for every consumer - `<texture>` display
14
+ // and shader sampling both follow it - so a nearest texture upscales with
15
+ // hard pixels everywhere (the retro/pixel-art path: render small, display
16
+ // big). No mipmaps exist.
17
+ //
18
+ // Combining several passes is a render-tree job, not a shader one: stack
19
+ // `<texture>` elements and set their `blendMode` (e.g. `blendMode="plus"` for
20
+ // an additive pass over a base pass) instead of writing a pass that samples
21
+ // both. WITHIN one pipeline draw, `blend: "add"` accumulates overlapping
22
+ // geometry additively (order-independent, no sorting); anything else draws
23
+ // with GL blending disabled and overwrites.
10
24
 
11
25
  import { createEffect, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
12
26
  import * as gpu from "flux:gpu"
@@ -18,6 +32,11 @@ import * as gpu from "flux:gpu"
18
32
  // owner: a leak until unmount, then a double-free against manual destroys.
19
33
  export type CreateOptions = { manual?: boolean }
20
34
 
35
+ // Sampling options every texture-producing create* helper accepts, applied at
36
+ // creation as a property of the texture id (there is no set-sampler-later).
37
+ export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode }
38
+ export type { FilterMode, WrapMode } from "flux:gpu"
39
+
21
40
  // Re-exported so callers that depend on @solidrt/core -- like @solidrt/components
22
41
  // -- need not import flux directly: destroyTexture for the manual-cleanup path
23
42
  // (textures made outside a reactive scope, e.g. after an await, are not
@@ -42,7 +61,7 @@ export {
42
61
  // its buffer gained or lost dynamic geometry; destroyBuffer is the manual
43
62
  // cleanup path for buffers created outside a reactive scope.
44
63
  export { destroyBuffer, setDrawCount } from "flux:gpu"
45
- export type { Topology, VertexAttribute } from "flux:gpu"
64
+ export type { BlendMode, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
46
65
 
47
66
  // The raw shading layer, re-exported as-is - no reactive wrapper, the app
48
67
  // owns these lifetimes. compileShader compiles one stage from complete GLSL
@@ -54,7 +73,10 @@ export type { Topology, VertexAttribute } from "flux:gpu"
54
73
  export { compileShader, destroyProgram, destroyShader, linkProgram } from "flux:gpu"
55
74
 
56
75
  // captureSnapshot renders a node to a texture and readTexture reads any
57
- // texture's bytes back. Re-exported raw (no reactive auto-cleanup wrapper):
76
+ // texture's bytes back. A laid-out node captures its layout box; a `d-*` node
77
+ // captures its painted box - its own w/h when set, else the nearest laid-out
78
+ // ancestor's box, its x/y offset mapped to the texture origin. Re-exported raw
79
+ // (no reactive auto-cleanup wrapper):
58
80
  // captureSnapshot resolves asynchronously, by which point the reactive owner is
59
81
  // no longer current, so the caller owns the returned id and frees it with
60
82
  // destroyTexture (as with any texture created after an await).
@@ -81,8 +103,13 @@ export { captureSnapshot, readTexture } from "flux:gpu"
81
103
  * yourself. Pass `{ manual: true }` to skip the auto-free and own the
82
104
  * disposal yourself even inside a reactive scope.
83
105
  */
84
- export function createTexture(data: Uint8Array, width: number, height: number, opts?: CreateOptions): number {
85
- let id = gpu.createTexture(data, width, height)
106
+ export function createTexture(
107
+ data: Uint8Array,
108
+ width: number,
109
+ height: number,
110
+ opts?: CreateOptions & SamplerOptions,
111
+ ): number {
112
+ let id = gpu.createTexture(data, width, height, opts)
86
113
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
87
114
  return id
88
115
  }
@@ -96,8 +123,13 @@ export function createTexture(data: Uint8Array, width: number, height: number, o
96
123
  * outside a reactive scope you must call `destroyTexture` (from flux:gpu)
97
124
  * yourself.
98
125
  */
99
- export function createMutableTexture(data: Uint8Array, width: number, height: number, opts?: CreateOptions): number {
100
- let id = gpu.createMutableTexture(data, width, height)
126
+ export function createMutableTexture(
127
+ data: Uint8Array,
128
+ width: number,
129
+ height: number,
130
+ opts?: CreateOptions & SamplerOptions,
131
+ ): number {
132
+ let id = gpu.createMutableTexture(data, width, height, opts)
101
133
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
102
134
  return id
103
135
  }
@@ -106,26 +138,38 @@ export function createMutableTexture(data: Uint8Array, width: number, height: nu
106
138
  * Compiles a GLSL ES 3.00 fragment shader and renders it into a texture,
107
139
  * returning the texture id (usable anywhere a normal texture id is, e.g.
108
140
  * `<texture src>`). The fragment body may reference `vUV` (0..1, top-left
109
- * origin), `iResolution`, `iTime`, and any `uniform float` it declares; drive
110
- * their values with `<texture src={id} params={{...}} />` (preferred) or, when
111
- * there is no `<texture>` element for it, imperatively with `setShaderParams`.
141
+ * origin), `iResolution`, `iTime`, and any uniform it declares (`float`/`int`
142
+ * scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
143
+ * array); drive their values with `<texture src={id} params={{...}} />`
144
+ * (preferred) or, when there is no `<texture>` element for it, imperatively
145
+ * with `setShaderParams`.
112
146
  * `textures` binds each declared `uniform sampler2D` to an existing texture id
113
- * (e.g. a camera or decoded image) so the shader can read it; those inputs are
114
- * re-sampled on every params update, so live sources stay current. Frees the
147
+ * (e.g. a camera or decoded image, or another shader/pipeline target) so the
148
+ * shader can read it; bound inputs are live dependencies, so the shader
149
+ * re-renders whenever a source changes - including a sampled target
150
+ * re-rendering, transitively through chains. Frees the
115
151
  * texture and shader program when the reactive owner is disposed (opt out
116
152
  * with `{ manual: true }`); create outside any reactive scope for
117
153
  * app-lifetime shaders. For a shader whose source or inputs change
118
154
  * reactively, use {@link createShaderMemo} instead.
155
+ *
156
+ * That preamble (`#version 300 es`, precision, `vUV`, `iResolution`, `iTime`,
157
+ * `fragColor`) is injected only into sources that do not declare their own
158
+ * `#version` line. A source starting with `#version 300 es` compiles exactly
159
+ * as written, so a shader carrying its own uniform names - one ported from
160
+ * elsewhere - runs unchanged here without dropping to compileShader /
161
+ * linkProgram. The built-in vertex stage still supplies `vUV`; declare
162
+ * `in vec2 vUV;` yourself to read it.
119
163
  */
120
164
  export function createShader(
121
165
  fragmentSrc: string,
122
166
  width: number,
123
167
  height: number,
124
- params?: Record<string, number>,
168
+ params?: gpu.ShaderParams,
125
169
  textures?: Record<string, number>,
126
- opts?: CreateOptions,
170
+ opts?: CreateOptions & SamplerOptions,
127
171
  ): number {
128
- let id = gpu.createShader(fragmentSrc, width, height, params, textures)
172
+ let id = gpu.createShader(fragmentSrc, width, height, params, textures, opts)
129
173
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
130
174
  return id
131
175
  }
@@ -146,37 +190,52 @@ export function createShaderTarget(
146
190
  width: number,
147
191
  height: number,
148
192
  opts?: {
149
- params?: Record<string, number>
193
+ params?: gpu.ShaderParams
150
194
  textures?: Record<string, number>
151
195
  attributes?: gpu.VertexAttribute[]
152
196
  buffer?: number
153
197
  topology?: gpu.Topology
154
198
  vertexCount?: number
155
199
  depth?: boolean
200
+ depthWrite?: boolean
201
+ blend?: gpu.BlendMode
156
202
  clearColor?: [number, number, number, number]
157
- } & CreateOptions,
203
+ } & CreateOptions &
204
+ SamplerOptions,
158
205
  ): number {
159
206
  let id = gpu.createShaderTarget(program, width, height, opts)
160
207
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
161
208
  return id
162
209
  }
163
210
 
164
- /** The reactive shader description `createShaderMemo` builds from. */
211
+ /** The reactive shader description `createShaderMemo` builds from. Sampling
212
+ * (`filter`/`wrap`) is creation-time state, so changing it rebuilds at a
213
+ * fresh id, like a fragment-source or sampler-binding change. */
165
214
  export type ShaderSpec = {
166
215
  fragmentSrc: string
167
216
  width: number
168
217
  height: number
169
- params?: Record<string, number>
218
+ params?: gpu.ShaderParams
170
219
  textures?: Record<string, number>
220
+ } & SamplerOptions
221
+
222
+ // Shallow name->value equality for params/textures records; treats undefined
223
+ // as the empty record. A param value may be a number or a flat number array
224
+ // (typed uniforms), so arrays compare elementwise.
225
+ function sameValue(a: number | number[] | undefined, b: number | number[] | undefined): boolean {
226
+ if (a === b) return true
227
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
228
+ return a.length === b.length && a.every((v, i) => v === b[i])
171
229
  }
172
230
 
173
- // Shallow name->number equality for params/textures records; treats undefined
174
- // as the empty record.
175
- function sameRecord(a: Record<string, number> | undefined, b: Record<string, number> | undefined): boolean {
231
+ function sameRecord(
232
+ a: Record<string, number | number[]> | undefined,
233
+ b: Record<string, number | number[]> | undefined,
234
+ ): boolean {
176
235
  if (a === b) return true
177
236
  let ka = a ? Object.keys(a) : []
178
237
  let kb = b ? Object.keys(b) : []
179
- return ka.length === kb.length && ka.every(k => a![k] === b![k])
238
+ return ka.length === kb.length && ka.every(k => sameValue(a![k], b![k]))
180
239
  }
181
240
 
182
241
  /**
@@ -191,28 +250,57 @@ function sameRecord(a: Record<string, number> | undefined, b: Record<string, num
191
250
  * so the swap never paints a blank frame. The current id is freed when the
192
251
  * owning scope is disposed. Data textures need no analog: `uploadTexture` and
193
252
  * `resizeTexture` already cover their reactive changes id-stably.
253
+ *
254
+ * `onError` makes a failed rebuild survivable. Without it a shader that does
255
+ * not compile throws from inside the effect, where no caller can catch it;
256
+ * with it the error is handed to you and the last shader that DID compile
257
+ * stays current - id, size, params and accessor all unchanged - so the app
258
+ * keeps drawing the previous frame's shader instead of tearing down. That is
259
+ * the normal case whenever the source is not known-good: a shader editor, live
260
+ * coding, or a dialect ported from elsewhere. The initial compile is not
261
+ * covered: it throws at the call site, where an ordinary try/catch works and
262
+ * there is no previous shader to fall back to.
194
263
  */
195
- export function createShaderMemo(spec: () => ShaderSpec): () => number {
264
+ export function createShaderMemo(
265
+ spec: () => ShaderSpec,
266
+ opts?: { onError?: (error: unknown) => void },
267
+ ): () => number {
268
+ let make = (s: ShaderSpec) =>
269
+ gpu.createShader(s.fragmentSrc, s.width, s.height, s.params, s.textures, { filter: s.filter, wrap: s.wrap })
196
270
  let current = untrack(spec)
197
- let currentId = gpu.createShader(current.fragmentSrc, current.width, current.height, current.params, current.textures)
271
+ let currentId = make(current)
198
272
  let [id, setId] = createSignal(currentId)
199
273
  createEffect(spec, next => {
200
- if (next.fragmentSrc === current.fragmentSrc && sameRecord(next.textures, current.textures)) {
201
- // Program and inputs unchanged: mutate in place, the id stays stable.
202
- if (next.width !== current.width || next.height !== current.height) {
203
- gpu.setShaderSize(currentId, next.width, next.height)
204
- }
205
- if (!sameRecord(next.params, current.params) && next.params) {
206
- gpu.setShaderParams(currentId, next.params)
274
+ try {
275
+ if (
276
+ next.fragmentSrc === current.fragmentSrc &&
277
+ sameRecord(next.textures, current.textures) &&
278
+ next.filter === current.filter &&
279
+ next.wrap === current.wrap
280
+ ) {
281
+ // Program and inputs unchanged: mutate in place, the id stays stable.
282
+ if (next.width !== current.width || next.height !== current.height) {
283
+ gpu.setShaderSize(currentId, next.width, next.height)
284
+ }
285
+ if (!sameRecord(next.params, current.params) && next.params) {
286
+ gpu.setShaderParams(currentId, next.params)
287
+ }
288
+ current = next
289
+ return
207
290
  }
291
+ // Compile before touching any state: a throw here must leave `current`,
292
+ // `currentId` and the accessor all still pointing at the last shader
293
+ // that worked, which is what makes onError's keep-last-good real.
294
+ let rebuilt = make(next)
295
+ let old = currentId
208
296
  current = next
209
- return
297
+ currentId = rebuilt
298
+ setId(rebuilt)
299
+ gpu.destroyTexture(old)
300
+ } catch (error) {
301
+ if (!opts?.onError) throw error
302
+ opts.onError(error)
210
303
  }
211
- let old = currentId
212
- current = next
213
- currentId = gpu.createShader(next.fragmentSrc, next.width, next.height, next.params, next.textures)
214
- setId(currentId)
215
- gpu.destroyTexture(old)
216
304
  })
217
305
  if (getOwner()) onCleanup(() => gpu.destroyTexture(currentId))
218
306
  return id
@@ -232,12 +320,19 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
232
320
  * e.g. `<texture src>`). Unlike `createShader` the vertex stage is yours:
233
321
  * declare `in` attributes matching `opts.attributes` (one interleaved vertex
234
322
  * in `opts.buffer`, a {@link createBuffer} id) and your own varyings toward
235
- * the fragment stage. Both sources may reference `iResolution`/`iTime` and any
236
- * `uniform float` they declare; drive values with `<texture src={id}
237
- * params={{...}} />` or `setShaderParams`, exactly like a fragment shader.
323
+ * the fragment stage. Both sources may reference `iResolution`/`iTime` and
324
+ * any uniform they declare (`float`/`int` scalars from a number,
325
+ * `vec2`/`vec3`/`vec4`/`mat4` from a flat number array); drive values with
326
+ * `<texture src={id} params={{...}} />` or `setShaderParams`, exactly like a
327
+ * fragment shader.
238
328
  * `opts.depth` attaches a private depth buffer (cleared + tested per render);
239
- * `opts.vertexCount` defaults to the whole buffer and can be changed later
240
- * with `setDrawCount`. Frees the texture and GL program when the reactive
329
+ * `opts.depthWrite: false` (requires depth) keeps the test but stops the
330
+ * draw from writing depth. `opts.blend: "add"` makes the draw accumulate
331
+ * overlapping geometry additively (order-independent, no sorting) instead of
332
+ * overwriting; a depth-tested additive pass is `{ depth: true, blend: "add",
333
+ * depthWrite: false }` - each option only does what it says, neither implies
334
+ * the other. `opts.vertexCount` defaults to the whole buffer and can be
335
+ * changed later with `setDrawCount`. Frees the texture and GL program when the reactive
241
336
  * owner is disposed (opt out with `opts.manual`); create outside any reactive
242
337
  * scope for app-lifetime pipelines.
243
338
  */
@@ -247,15 +342,18 @@ export function createPipeline(
247
342
  width: number,
248
343
  height: number,
249
344
  opts?: {
250
- params?: Record<string, number>
345
+ params?: gpu.ShaderParams
251
346
  textures?: Record<string, number>
252
347
  attributes?: gpu.VertexAttribute[]
253
348
  buffer?: number
254
349
  topology?: gpu.Topology
255
350
  vertexCount?: number
256
351
  depth?: boolean
352
+ depthWrite?: boolean
353
+ blend?: gpu.BlendMode
257
354
  clearColor?: [number, number, number, number]
258
- } & CreateOptions,
355
+ } & CreateOptions &
356
+ SamplerOptions,
259
357
  ): number {
260
358
  let id = gpu.createPipeline(vertexSrc, fragmentSrc, width, height, opts)
261
359
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
@@ -8,9 +8,25 @@ declare module "*.svg" {
8
8
  export default content
9
9
  }
10
10
 
11
+ // Text asset imports: `import src from "./effect.glsl" with { type: "text" }`.
12
+ // The bundler inlines the file's UTF-8 contents as a string literal, so a
13
+ // shader source travels in the bundle and needs no runtime read.
14
+ declare module "*.glsl" {
15
+ const content: string
16
+ export default content
17
+ }
18
+ declare module "*.vert" {
19
+ const content: string
20
+ export default content
21
+ }
22
+ declare module "*.frag" {
23
+ const content: string
24
+ export default content
25
+ }
26
+
11
27
  // Binary asset imports: `import data from "./pic.png" with { type: "binary" }`.
12
28
  // The bundler inlines the file's bytes as a Uint8Array (see packages/cli
13
- // bundler `binaryImport`); feed it straight into createImage/decodeImage.
29
+ // bundler `inlineImport`); feed it straight into createImage/decodeImage.
14
30
  declare module "*.png" {
15
31
  const bytes: Uint8Array
16
32
  export default bytes
package/src/types.d.ts CHANGED
@@ -299,8 +299,12 @@ export interface WindowProps extends LayoutProps, PointerProps {
299
299
  export interface WindowShaderProps {
300
300
  /** Linked program handle from linkProgram. */
301
301
  program: number
302
- /** Float uniforms filled by name, paced to the next real repaint. */
303
- params?: Record<string, number>
302
+ /**
303
+ * Uniforms filled by name, paced to the next real repaint. A number drives
304
+ * a scalar (`float`/`int`); a flat number array drives the declared GLSL
305
+ * type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
306
+ */
307
+ params?: Record<string, number | number[]>
304
308
  /** Extra sampler2D inputs: uniform name to texture id. */
305
309
  textures?: Record<string, number>
306
310
  /** Vertices drawn (attributeless triangles). Default 3, the covering triangle. */
@@ -434,6 +438,9 @@ export interface TextureProps extends Position, PaintProps, PointerProps {
434
438
  srcH?: number
435
439
  // Shader uniform values, when src names a shader texture. Applied at the
436
440
  // next repaint (not synchronously), so a fast-changing signal stays paced
437
- // to real frames rather than triggering a GL render pass per write.
438
- params?: Record<string, number>
441
+ // to real frames rather than triggering a GL render pass per write. A
442
+ // number drives a scalar (`float`/`int`); a flat number array drives the
443
+ // declared GLSL type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major)
444
+ // for `mat4`.
445
+ params?: Record<string, number | number[]>
439
446
  }