@solidrt/core 0.0.38 → 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.
- package/AGENTS.md +1 -1
- package/examples/README.md +10 -3
- package/examples/gpu-particles.tsx +96 -0
- package/examples/gpu-raw-program.tsx +76 -0
- package/examples/gpu-shader.tsx +43 -8
- package/examples/gpu-texture-blend.tsx +78 -0
- package/examples/text-import.tsx +29 -0
- package/examples/wave.glsl +13 -0
- package/examples/window-shader-history.tsx +62 -0
- package/examples/window-shader.tsx +71 -0
- package/package.json +5 -5
- package/src/gamepad.ts +6 -0
- package/src/gpu.ts +190 -42
- package/src/index.ts +1 -0
- package/src/runtime-modules.d.ts +34 -4
- package/src/types.d.ts +64 -4
- package/src/window.ts +27 -9
package/AGENTS.md
CHANGED
|
@@ -84,7 +84,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
|
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
87
|
-
2.0.0-beta.
|
|
87
|
+
2.0.0-beta.26); bun resolves them from peerDependencies.
|
|
88
88
|
|
|
89
89
|
## Element model (the parts that are easy to get wrong)
|
|
90
90
|
|
package/examples/README.md
CHANGED
|
@@ -20,7 +20,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
|
|
|
20
20
|
- `pointer-local-coords.tsx` - the three pointer coordinate frames (`clientX` window, `localX` the handling node's own frame, `parentX` its path-parent's frame - where the node's x/y live) and the transform-proof drag idiom: grab offset from `localX` at down, place with `parentX - offset` on moves. Exact inside rotated/scaled ancestors and when the pointer leaves the node mid-drag.
|
|
21
21
|
|
|
22
22
|
## Performance
|
|
23
|
-
- `repaint-boundary.tsx` - `repaintBoundary` on a `<view>` to keep static content from rebuilding while a neighbor animates: `{true}` retains the recorded draw list, `"snapshot"` also retains the rasterized pixels as a GPU texture (for raster-expensive, screen-aligned, static subtrees).
|
|
23
|
+
- `repaint-boundary.tsx` - `repaintBoundary` on a `<view>` to keep static content from rebuilding while a neighbor animates: `{true}` retains the recorded draw list, `"snapshot"` also retains the rasterized pixels as a GPU texture (for raster-expensive, screen-aligned, static subtrees). `"snapshot-no-aa"` rasterizes without anti-aliasing: cheaper, fine for text and axis-aligned rects, hard-edged on vector content.
|
|
24
24
|
|
|
25
25
|
## Scrolling
|
|
26
26
|
- `scroll.tsx` - `createScroll`, the headless scroll primitive: it owns only the clamped offset (re-clamped on layout); you supply the viewport/content nodes via refs, apply the offset to `scrollX`/`scrollY`, and wire input (e.g. `onWheel`) to `scrollBy` yourself.
|
|
@@ -35,7 +35,13 @@ 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.
|
|
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.
|
|
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.
|
|
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.
|
|
39
45
|
|
|
40
46
|
## Sound
|
|
41
47
|
- `sound.tsx` - `createSound`: decode a clip once from bytes (here a binary import), replay cheaply; `overlap` stacking vs single-voice, `playing()` signal, release on unmount. Points to `createSoundStream` for long tracks streamed from a path.
|
|
@@ -44,4 +50,5 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
|
|
|
44
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`.
|
|
45
51
|
|
|
46
52
|
## Bundling assets
|
|
47
|
-
- `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).
|
|
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 />)
|
package/examples/gpu-shader.tsx
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
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={
|
|
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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// `previous: true` on the window shader retains the last resolved frame as a
|
|
2
|
+
// second layer the program samples as uPrevious, rotated each frame - a
|
|
3
|
+
// one-frame history. Here it draws a motion echo behind the orbiting square;
|
|
4
|
+
// click to toggle the echo term off and compare with the plain frame.
|
|
5
|
+
import { render, onFrame, createSignal } from "@solidrt/core"
|
|
6
|
+
import { compileShader, destroyShader, linkProgram } from "@solidrt/core/gpu"
|
|
7
|
+
|
|
8
|
+
let VERTEX = `#version 300 es
|
|
9
|
+
precision highp float;
|
|
10
|
+
out vec2 vUV;
|
|
11
|
+
void main() {
|
|
12
|
+
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
|
13
|
+
// uSource/uPrevious are top-left origin; flip v so the frame lands upright.
|
|
14
|
+
vUV = vec2(p.x, 1.0 - p.y);
|
|
15
|
+
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
|
16
|
+
}
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
let ECHO = `
|
|
20
|
+
uniform sampler2D uSource;
|
|
21
|
+
uniform sampler2D uPrevious;
|
|
22
|
+
uniform float uEcho;
|
|
23
|
+
in vec2 vUV;
|
|
24
|
+
void main() {
|
|
25
|
+
vec4 cur = texture(uSource, vUV);
|
|
26
|
+
vec4 prev = texture(uPrevious, vUV);
|
|
27
|
+
// Brightest of the current frame and the decayed previous one: motion
|
|
28
|
+
// leaves a one-frame ghost trailing it.
|
|
29
|
+
fragColor = max(cur, prev * uEcho);
|
|
30
|
+
}
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
function App() {
|
|
34
|
+
let vs = compileShader("vertex", VERTEX)
|
|
35
|
+
let fs = compileShader("fragment", ECHO, { header: true })
|
|
36
|
+
let echoProgram = linkProgram(vs, fs)
|
|
37
|
+
destroyShader(vs)
|
|
38
|
+
destroyShader(fs)
|
|
39
|
+
|
|
40
|
+
let [angle, setAngle] = createSignal(0)
|
|
41
|
+
let [echo, setEcho] = createSignal(0.65)
|
|
42
|
+
onFrame(tick => setAngle(tick / 350))
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<window
|
|
46
|
+
shader={{ program: echoProgram, params: { uEcho: echo() }, previous: true }}
|
|
47
|
+
onPointerDown={() => setEcho(e => (e > 0 ? 0 : 0.65))}
|
|
48
|
+
alignItems="center"
|
|
49
|
+
justifyContent="center"
|
|
50
|
+
>
|
|
51
|
+
<rect position="absolute" top={0} right={0} bottom={0} left={0} color="#101826" />
|
|
52
|
+
<view width={70} height={70} x={Math.cos(angle()) * 150} y={Math.sin(angle()) * 150}>
|
|
53
|
+
<rect width={70} height={70} radius={16} color="#7ad0ff" />
|
|
54
|
+
</view>
|
|
55
|
+
<text position="absolute" bottom={24} fontSize={14} color="#99aabb">
|
|
56
|
+
Click to toggle the uPrevious echo
|
|
57
|
+
</text>
|
|
58
|
+
</window>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
render(() => <App />)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The window shader: the finished frame renders into a runtime-owned layer
|
|
2
|
+
// texture and a linked program draws over it into the window, as the last
|
|
3
|
+
// step before present. The program samples the frame as uSource (top-left
|
|
4
|
+
// origin - the vertex stage flips v when mapping onto the window), gets
|
|
5
|
+
// iResolution in physical pixels, and draws attributeless at vertexCount
|
|
6
|
+
// (default 3, the covering triangle).
|
|
7
|
+
//
|
|
8
|
+
// Click anywhere to toggle the warp amount between 0 and 1: at 0 the program
|
|
9
|
+
// is an identity pass, which must be indistinguishable from no shader at all
|
|
10
|
+
// (the orientation/half-pixel regression check from the plan).
|
|
11
|
+
import { render, onFrame, createSignal } from "@solidrt/core"
|
|
12
|
+
import { compileShader, destroyShader, linkProgram } from "@solidrt/core/gpu"
|
|
13
|
+
|
|
14
|
+
let VERTEX = `#version 300 es
|
|
15
|
+
precision highp float;
|
|
16
|
+
out vec2 vUV;
|
|
17
|
+
void main() {
|
|
18
|
+
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
|
19
|
+
// uSource is top-left origin; flip v so the frame lands upright on the
|
|
20
|
+
// window (the one flip of the frame path, done here in the vertex stage).
|
|
21
|
+
vUV = vec2(p.x, 1.0 - p.y);
|
|
22
|
+
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
|
23
|
+
}
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
// { header: true } declares #version, precision, iResolution/iTime and
|
|
27
|
+
// fragColor; uSource, vUV, and the app's own uniforms are declared here.
|
|
28
|
+
let WARP = `
|
|
29
|
+
uniform sampler2D uSource;
|
|
30
|
+
uniform float uAmount;
|
|
31
|
+
in vec2 vUV;
|
|
32
|
+
void main() {
|
|
33
|
+
vec2 uv = vUV;
|
|
34
|
+
uv.x += sin(uv.y * 24.0 + iTime * 3.0) * 0.012 * uAmount;
|
|
35
|
+
uv.y += sin(uv.x * 18.0 - iTime * 2.0) * 0.012 * uAmount;
|
|
36
|
+
fragColor = texture(uSource, uv);
|
|
37
|
+
}
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
function App() {
|
|
41
|
+
let vs = compileShader("vertex", VERTEX)
|
|
42
|
+
let fs = compileShader("fragment", WARP, { header: true })
|
|
43
|
+
let warp = linkProgram(vs, fs)
|
|
44
|
+
destroyShader(vs)
|
|
45
|
+
destroyShader(fs)
|
|
46
|
+
|
|
47
|
+
let [time, setTime] = createSignal(0)
|
|
48
|
+
let [amount, setAmount] = createSignal(1)
|
|
49
|
+
onFrame(tick => setTime(tick / 1000))
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<window
|
|
53
|
+
shader={{ program: warp, params: { iTime: time(), uAmount: amount() } }}
|
|
54
|
+
onPointerDown={() => setAmount(a => (a > 0 ? 0 : 1))}
|
|
55
|
+
flexDirection="column"
|
|
56
|
+
gap={12}
|
|
57
|
+
alignItems="center"
|
|
58
|
+
justifyContent="center"
|
|
59
|
+
>
|
|
60
|
+
<text fontSize={28} color="#222">Window shader</text>
|
|
61
|
+
<view flexDirection="row" gap={12}>
|
|
62
|
+
<rect w={90} h={90} radius={12} color="#0077ff" />
|
|
63
|
+
<rect w={90} h={90} radius={12} color="#ff6a00" />
|
|
64
|
+
<rect w={90} h={90} radius={12} color="#00c46a" />
|
|
65
|
+
</view>
|
|
66
|
+
<text fontSize={14} color="#666">Click to toggle warp (identity at 0)</text>
|
|
67
|
+
</window>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
render(() => <App />)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.40",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"colord": "^2.9.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@solidrt/flux-types": "0.0.
|
|
30
|
+
"@solidrt/flux-types": "0.0.40"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@solidjs/signals": "2.0.0-beta.
|
|
34
|
-
"@solidjs/universal": "2.0.0-beta.
|
|
35
|
-
"solid-js": "2.0.0-beta.
|
|
33
|
+
"@solidjs/signals": "2.0.0-beta.26",
|
|
34
|
+
"@solidjs/universal": "2.0.0-beta.26",
|
|
35
|
+
"solid-js": "2.0.0-beta.26"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/gamepad.ts
CHANGED
|
@@ -15,6 +15,12 @@ import { on } from "srt:events"
|
|
|
15
15
|
* "back", "guide", "leftShoulder", "rightShoulder", "leftStick",
|
|
16
16
|
* "rightStick"). `axes` has sticks ("leftX", "leftY", "rightX", "rightY") in
|
|
17
17
|
* -1..1 and triggers ("leftTrigger", "rightTrigger") in 0..1.
|
|
18
|
+
*
|
|
19
|
+
* The snapshot is a faithful report. Note that pressing "back" (select) on a
|
|
20
|
+
* mapped pad ALSO emits the `back` event (see onBack) - it is the pad-side
|
|
21
|
+
* sibling of Android's system back, the runtime's exit-to-launcher gesture.
|
|
22
|
+
* Apps that bind "back" for their own controls should preventDefault that
|
|
23
|
+
* event.
|
|
18
24
|
*/
|
|
19
25
|
export interface GamepadState {
|
|
20
26
|
/** Runtime instance id: unique per connection, not stable across reconnects. */
|
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,13 +61,35 @@ 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"
|
|
65
|
+
|
|
66
|
+
// The raw shading layer, re-exported as-is - no reactive wrapper, the app
|
|
67
|
+
// owns these lifetimes. compileShader compiles one stage from complete GLSL
|
|
68
|
+
// ES (or with the standard header via { header: true }); linkProgram links a
|
|
69
|
+
// vertex and a fragment stage into a program handle that backs any number of
|
|
70
|
+
// createShaderTarget calls (and compiles nothing per target); destroyShader /
|
|
71
|
+
// destroyProgram free by id space, either order safe against live targets.
|
|
72
|
+
// createShader/createPipeline remain the fused conveniences on top.
|
|
73
|
+
export { compileShader, destroyProgram, destroyShader, linkProgram } from "flux:gpu"
|
|
46
74
|
|
|
47
75
|
// captureSnapshot renders a node to a texture and readTexture reads any
|
|
48
|
-
// texture's bytes back.
|
|
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):
|
|
49
80
|
// captureSnapshot resolves asynchronously, by which point the reactive owner is
|
|
50
81
|
// no longer current, so the caller owns the returned id and frees it with
|
|
51
82
|
// destroyTexture (as with any texture created after an await).
|
|
83
|
+
//
|
|
84
|
+
// Together they are the one-shot bake path: draw something only the engine can
|
|
85
|
+
// produce (shaped text, an SVG, a themed view), capture it, read the pixels and
|
|
86
|
+
// process them on the CPU - baking a glyph atlas is the worked example. Not a
|
|
87
|
+
// rendering path: a capture rasterizes the subtree offscreen, reads it back to
|
|
88
|
+
// the CPU and re-uploads it, costing a full GPU -> CPU -> GPU round trip and a
|
|
89
|
+
// paint pass of latency every call. Batch captures (one paint pass services
|
|
90
|
+
// many), never run them per frame, and do not use them to feed live screen
|
|
91
|
+
// content into a shader - for that the source has to update in place (another
|
|
92
|
+
// pipeline's target, a camera texture).
|
|
52
93
|
export { captureSnapshot, readTexture } from "flux:gpu"
|
|
53
94
|
|
|
54
95
|
/**
|
|
@@ -62,8 +103,13 @@ export { captureSnapshot, readTexture } from "flux:gpu"
|
|
|
62
103
|
* yourself. Pass `{ manual: true }` to skip the auto-free and own the
|
|
63
104
|
* disposal yourself even inside a reactive scope.
|
|
64
105
|
*/
|
|
65
|
-
export function createTexture(
|
|
66
|
-
|
|
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)
|
|
67
113
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
68
114
|
return id
|
|
69
115
|
}
|
|
@@ -77,8 +123,13 @@ export function createTexture(data: Uint8Array, width: number, height: number, o
|
|
|
77
123
|
* outside a reactive scope you must call `destroyTexture` (from flux:gpu)
|
|
78
124
|
* yourself.
|
|
79
125
|
*/
|
|
80
|
-
export function createMutableTexture(
|
|
81
|
-
|
|
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)
|
|
82
133
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
83
134
|
return id
|
|
84
135
|
}
|
|
@@ -87,46 +138,104 @@ export function createMutableTexture(data: Uint8Array, width: number, height: nu
|
|
|
87
138
|
* Compiles a GLSL ES 3.00 fragment shader and renders it into a texture,
|
|
88
139
|
* returning the texture id (usable anywhere a normal texture id is, e.g.
|
|
89
140
|
* `<texture src>`). The fragment body may reference `vUV` (0..1, top-left
|
|
90
|
-
* origin), `iResolution`, `iTime`, and any
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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`.
|
|
93
146
|
* `textures` binds each declared `uniform sampler2D` to an existing texture id
|
|
94
|
-
* (e.g. a camera or decoded image
|
|
95
|
-
*
|
|
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
|
|
96
151
|
* texture and shader program when the reactive owner is disposed (opt out
|
|
97
152
|
* with `{ manual: true }`); create outside any reactive scope for
|
|
98
153
|
* app-lifetime shaders. For a shader whose source or inputs change
|
|
99
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.
|
|
100
163
|
*/
|
|
101
164
|
export function createShader(
|
|
102
165
|
fragmentSrc: string,
|
|
103
166
|
width: number,
|
|
104
167
|
height: number,
|
|
105
|
-
params?:
|
|
168
|
+
params?: gpu.ShaderParams,
|
|
106
169
|
textures?: Record<string, number>,
|
|
107
|
-
opts?: CreateOptions,
|
|
170
|
+
opts?: CreateOptions & SamplerOptions,
|
|
171
|
+
): number {
|
|
172
|
+
let id = gpu.createShader(fragmentSrc, width, height, params, textures, opts)
|
|
173
|
+
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
174
|
+
return id
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Creates a render target over a program from `linkProgram` and renders it
|
|
179
|
+
* once, returning the texture id (usable anywhere a normal texture id is,
|
|
180
|
+
* e.g. `<texture src>`; resize with `setShaderSize`, drive uniforms with
|
|
181
|
+
* `<texture params>` or `setShaderParams`). Many targets may share one
|
|
182
|
+
* program, and creating a target compiles nothing. The mesh options mirror
|
|
183
|
+
* `createPipeline`: a raw-linked program carries its own vertex stage, so a
|
|
184
|
+
* fullscreen pass is `{ vertexCount: 3 }` over a covering-triangle vertex
|
|
185
|
+
* stage. Frees the target when the reactive owner is disposed (opt out with
|
|
186
|
+
* `opts.manual`); the program is yours and outlives it.
|
|
187
|
+
*/
|
|
188
|
+
export function createShaderTarget(
|
|
189
|
+
program: number,
|
|
190
|
+
width: number,
|
|
191
|
+
height: number,
|
|
192
|
+
opts?: {
|
|
193
|
+
params?: gpu.ShaderParams
|
|
194
|
+
textures?: Record<string, number>
|
|
195
|
+
attributes?: gpu.VertexAttribute[]
|
|
196
|
+
buffer?: number
|
|
197
|
+
topology?: gpu.Topology
|
|
198
|
+
vertexCount?: number
|
|
199
|
+
depth?: boolean
|
|
200
|
+
depthWrite?: boolean
|
|
201
|
+
blend?: gpu.BlendMode
|
|
202
|
+
clearColor?: [number, number, number, number]
|
|
203
|
+
} & CreateOptions &
|
|
204
|
+
SamplerOptions,
|
|
108
205
|
): number {
|
|
109
|
-
let id = gpu.
|
|
206
|
+
let id = gpu.createShaderTarget(program, width, height, opts)
|
|
110
207
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
|
111
208
|
return id
|
|
112
209
|
}
|
|
113
210
|
|
|
114
|
-
/** 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. */
|
|
115
214
|
export type ShaderSpec = {
|
|
116
215
|
fragmentSrc: string
|
|
117
216
|
width: number
|
|
118
217
|
height: number
|
|
119
|
-
params?:
|
|
218
|
+
params?: gpu.ShaderParams
|
|
120
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])
|
|
121
229
|
}
|
|
122
230
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
231
|
+
function sameRecord(
|
|
232
|
+
a: Record<string, number | number[]> | undefined,
|
|
233
|
+
b: Record<string, number | number[]> | undefined,
|
|
234
|
+
): boolean {
|
|
126
235
|
if (a === b) return true
|
|
127
236
|
let ka = a ? Object.keys(a) : []
|
|
128
237
|
let kb = b ? Object.keys(b) : []
|
|
129
|
-
return ka.length === kb.length && ka.every(k => a![k]
|
|
238
|
+
return ka.length === kb.length && ka.every(k => sameValue(a![k], b![k]))
|
|
130
239
|
}
|
|
131
240
|
|
|
132
241
|
/**
|
|
@@ -141,28 +250,57 @@ function sameRecord(a: Record<string, number> | undefined, b: Record<string, num
|
|
|
141
250
|
* so the swap never paints a blank frame. The current id is freed when the
|
|
142
251
|
* owning scope is disposed. Data textures need no analog: `uploadTexture` and
|
|
143
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.
|
|
144
263
|
*/
|
|
145
|
-
export function createShaderMemo(
|
|
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 })
|
|
146
270
|
let current = untrack(spec)
|
|
147
|
-
let currentId =
|
|
271
|
+
let currentId = make(current)
|
|
148
272
|
let [id, setId] = createSignal(currentId)
|
|
149
273
|
createEffect(spec, next => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
157
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
|
|
158
296
|
current = next
|
|
159
|
-
|
|
297
|
+
currentId = rebuilt
|
|
298
|
+
setId(rebuilt)
|
|
299
|
+
gpu.destroyTexture(old)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (!opts?.onError) throw error
|
|
302
|
+
opts.onError(error)
|
|
160
303
|
}
|
|
161
|
-
let old = currentId
|
|
162
|
-
current = next
|
|
163
|
-
currentId = gpu.createShader(next.fragmentSrc, next.width, next.height, next.params, next.textures)
|
|
164
|
-
setId(currentId)
|
|
165
|
-
gpu.destroyTexture(old)
|
|
166
304
|
})
|
|
167
305
|
if (getOwner()) onCleanup(() => gpu.destroyTexture(currentId))
|
|
168
306
|
return id
|
|
@@ -182,12 +320,19 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
|
182
320
|
* e.g. `<texture src>`). Unlike `createShader` the vertex stage is yours:
|
|
183
321
|
* declare `in` attributes matching `opts.attributes` (one interleaved vertex
|
|
184
322
|
* in `opts.buffer`, a {@link createBuffer} id) and your own varyings toward
|
|
185
|
-
* the fragment stage. Both sources may reference `iResolution`/`iTime` and
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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.
|
|
188
328
|
* `opts.depth` attaches a private depth buffer (cleared + tested per render);
|
|
189
|
-
* `opts.
|
|
190
|
-
*
|
|
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
|
|
191
336
|
* owner is disposed (opt out with `opts.manual`); create outside any reactive
|
|
192
337
|
* scope for app-lifetime pipelines.
|
|
193
338
|
*/
|
|
@@ -197,15 +342,18 @@ export function createPipeline(
|
|
|
197
342
|
width: number,
|
|
198
343
|
height: number,
|
|
199
344
|
opts?: {
|
|
200
|
-
params?:
|
|
345
|
+
params?: gpu.ShaderParams
|
|
201
346
|
textures?: Record<string, number>
|
|
202
347
|
attributes?: gpu.VertexAttribute[]
|
|
203
348
|
buffer?: number
|
|
204
349
|
topology?: gpu.Topology
|
|
205
350
|
vertexCount?: number
|
|
206
351
|
depth?: boolean
|
|
352
|
+
depthWrite?: boolean
|
|
353
|
+
blend?: gpu.BlendMode
|
|
207
354
|
clearColor?: [number, number, number, number]
|
|
208
|
-
} & CreateOptions
|
|
355
|
+
} & CreateOptions &
|
|
356
|
+
SamplerOptions,
|
|
209
357
|
): number {
|
|
210
358
|
let id = gpu.createPipeline(vertexSrc, fragmentSrc, width, height, opts)
|
|
211
359
|
if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
|
package/src/index.ts
CHANGED
package/src/runtime-modules.d.ts
CHANGED
|
@@ -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 `
|
|
29
|
+
// bundler `inlineImport`); feed it straight into createImage/decodeImage.
|
|
14
30
|
declare module "*.png" {
|
|
15
31
|
const bytes: Uint8Array
|
|
16
32
|
export default bytes
|
|
@@ -83,10 +99,24 @@ declare module "srt:apps" {
|
|
|
83
99
|
export const available: boolean
|
|
84
100
|
/**
|
|
85
101
|
* An installed app: id, display name (the installed manifest's displayName,
|
|
86
|
-
* defaulting to the id) and current version id (manifest hash).
|
|
102
|
+
* defaulting to the id) and current version id (manifest hash). `updated` is
|
|
103
|
+
* when that version became current, in milliseconds since the epoch (0 when
|
|
104
|
+
* the store's timestamp is unreadable); a repush of an identical manifest
|
|
105
|
+
* installs nothing and leaves it alone. `size` is the version's
|
|
106
|
+
* manifest-declared size (bundle plus assets) - claimed rather than walked,
|
|
107
|
+
* so that listing stays cheap; `info()` reports what is actually on disk.
|
|
108
|
+
* `icon` is the manifest-declared icon's SVG source, ready for an `<svg>`
|
|
109
|
+
* src; absent when the app declares none (or the file is unreadable).
|
|
87
110
|
*/
|
|
88
|
-
export type InstalledApp = {
|
|
89
|
-
|
|
111
|
+
export type InstalledApp = {
|
|
112
|
+
id: string
|
|
113
|
+
name: string
|
|
114
|
+
icon?: string
|
|
115
|
+
version: string
|
|
116
|
+
updated: number
|
|
117
|
+
size: number
|
|
118
|
+
}
|
|
119
|
+
/** Installed apps, most recently updated first. */
|
|
90
120
|
export function list(): InstalledApp[]
|
|
91
121
|
/**
|
|
92
122
|
* A stored version: id (manifest hash), bytes on disk, whether it is the
|
package/src/types.d.ts
CHANGED
|
@@ -275,6 +275,49 @@ export interface WindowProps extends LayoutProps, PointerProps {
|
|
|
275
275
|
children?: Children
|
|
276
276
|
title?: string
|
|
277
277
|
fullscreen?: boolean
|
|
278
|
+
/**
|
|
279
|
+
* Run the window's finished frame through a GPU program as the last step
|
|
280
|
+
* before it reaches the screen. While declared, the frame renders into a
|
|
281
|
+
* runtime-owned layer texture the program samples; removing the prop
|
|
282
|
+
* restores the direct path and frees the layer. Everything else about the
|
|
283
|
+
* program (compiling, linking, lifetime) is the raw shading layer's:
|
|
284
|
+
* see compileShader/linkProgram.
|
|
285
|
+
*/
|
|
286
|
+
shader?: WindowShaderProps | null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A window shader declaration. The program reads the frame through
|
|
291
|
+
* `uniform sampler2D uSource` (top-left origin, like every sampled texture -
|
|
292
|
+
* so a vertex stage mapping it onto the window flips the v coordinate) and
|
|
293
|
+
* is drawn attributeless as triangles, `vertexCount` vertices fetched via
|
|
294
|
+
* gl_VertexID. `iResolution`, filled by name, is the window size in physical
|
|
295
|
+
* pixels (the pass covers exactly that). The window is cleared to opaque
|
|
296
|
+
* black first, so geometry that does not cover it still presents a defined
|
|
297
|
+
* frame.
|
|
298
|
+
*/
|
|
299
|
+
export interface WindowShaderProps {
|
|
300
|
+
/** Linked program handle from linkProgram. */
|
|
301
|
+
program: 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[]>
|
|
308
|
+
/** Extra sampler2D inputs: uniform name to texture id. */
|
|
309
|
+
textures?: Record<string, number>
|
|
310
|
+
/** Vertices drawn (attributeless triangles). Default 3, the covering triangle. */
|
|
311
|
+
vertexCount?: number
|
|
312
|
+
/**
|
|
313
|
+
* Retain the last frame as a second layer the program samples as
|
|
314
|
+
* `uniform sampler2D uPrevious` (one-frame history: motion echo, frame
|
|
315
|
+
* differencing). Costs one extra window-sized texture while declared.
|
|
316
|
+
* Until a second frame exists uPrevious samples opaque black. Only declare
|
|
317
|
+
* the uPrevious uniform together with this flag - without it the uniform
|
|
318
|
+
* stays at unit 0 and aliases uSource. Default false.
|
|
319
|
+
*/
|
|
320
|
+
previous?: boolean
|
|
278
321
|
}
|
|
279
322
|
|
|
280
323
|
export interface ViewProps extends LayoutProps, TransformProps, PointerProps {
|
|
@@ -297,8 +340,13 @@ export interface ViewProps extends LayoutProps, TransformProps, PointerProps {
|
|
|
297
340
|
* on layout-size or display-scale changes. Content painted outside the
|
|
298
341
|
* element's layout box is cropped, and ancestor scale animations smear the
|
|
299
342
|
* bitmap; best for screen-aligned, static, raster-expensive content.
|
|
343
|
+
*
|
|
344
|
+
* "snapshot-no-aa" is "snapshot" rasterized without anti-aliasing: cheaper
|
|
345
|
+
* (no multisampled scratch, one render pass), but vector content - svg
|
|
346
|
+
* paths, rounded corners, rotated edges - comes out hard-edged. Text and
|
|
347
|
+
* axis-aligned rects look identical, so prefer it for plain UI panels.
|
|
300
348
|
*/
|
|
301
|
-
repaintBoundary?: boolean | "snapshot"
|
|
349
|
+
repaintBoundary?: boolean | "snapshot" | "snapshot-no-aa"
|
|
302
350
|
}
|
|
303
351
|
|
|
304
352
|
// draw primitives
|
|
@@ -363,7 +411,16 @@ export interface TextProps extends Position, PaintProps, PointerProps {
|
|
|
363
411
|
maxLines?: number
|
|
364
412
|
}
|
|
365
413
|
|
|
366
|
-
|
|
414
|
+
/**
|
|
415
|
+
* A raster draw uses only part of a paint. `blendMode` applies, which is how
|
|
416
|
+
* two GPU layers composite in the tree (a solid pass plus an additive pass)
|
|
417
|
+
* without a hand-written compositing shader. `color` contributes its alpha
|
|
418
|
+
* only, as an opacity multiplier; its RGB does not tint, and a gradient does
|
|
419
|
+
* not replace the texture. `drawStyle` and the stroke props have no effect.
|
|
420
|
+
* Texture alpha is premultiplied, so additive modes need no manual
|
|
421
|
+
* premultiplication.
|
|
422
|
+
*/
|
|
423
|
+
export interface TextureProps extends Position, PaintProps, PointerProps {
|
|
367
424
|
src?: number
|
|
368
425
|
/**
|
|
369
426
|
* How the texture's pixels map to the element box (CSS object-fit).
|
|
@@ -381,6 +438,9 @@ export interface TextureProps extends Position, PointerProps {
|
|
|
381
438
|
srcH?: number
|
|
382
439
|
// Shader uniform values, when src names a shader texture. Applied at the
|
|
383
440
|
// next repaint (not synchronously), so a fast-changing signal stays paced
|
|
384
|
-
// to real frames rather than triggering a GL render pass per write.
|
|
385
|
-
|
|
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[]>
|
|
386
446
|
}
|
package/src/window.ts
CHANGED
|
@@ -190,22 +190,38 @@ export function onWindowBlur(fn: () => void) {
|
|
|
190
190
|
|
|
191
191
|
export type BackEvent = { preventDefault: () => void }
|
|
192
192
|
|
|
193
|
-
// App handlers for the window-level back event,
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
193
|
+
// App handlers for the window-level back event, as a stack: the last one
|
|
194
|
+
// registered is offered the event first, and the first to prevent ends the
|
|
195
|
+
// dispatch. Back is a pop, so the thing most recently put on screen has to
|
|
196
|
+
// answer for it - a dialog that opens over a screen registers after it and must
|
|
197
|
+
// win, and registration order tracks mount order (a parent sets up before its
|
|
198
|
+
// children), so reverse order also reads as innermost-first. Kept in a local
|
|
199
|
+
// registry rather than per-handler bus subscriptions so the default action runs
|
|
200
|
+
// exactly once, after the handlers have had their say.
|
|
201
|
+
let backHandlers: ((e: BackEvent) => void)[] = []
|
|
197
202
|
|
|
198
203
|
/**
|
|
199
204
|
* Calls `fn` on the user's back intent (Android back button/gesture, the
|
|
200
205
|
* desktop dev chord). Call `e.preventDefault()` when back means in-app
|
|
201
206
|
* navigation right now (close a modal, previous screen); unprevented, the
|
|
202
|
-
*
|
|
203
|
-
*
|
|
207
|
+
* event passes to the handler registered before this one, and if none of them
|
|
208
|
+
* prevents it either, to the default action: exit(). Apps without a handler
|
|
209
|
+
* exit on back everywhere, which is the correct zero-effort default.
|
|
210
|
+
*
|
|
211
|
+
* Handlers form a stack: the most recently registered runs first and the first
|
|
212
|
+
* to prevent ends the dispatch, so each screen or overlay owns one step of the
|
|
213
|
+
* back stack and none of them needs to know what the others are doing. A
|
|
214
|
+
* handler that does not prevent must not act either - the event is still on its
|
|
215
|
+
* way to whoever will handle it.
|
|
216
|
+
*
|
|
204
217
|
* Returns a cleanup function; also auto-cleans within a reactive scope.
|
|
205
218
|
*/
|
|
206
219
|
export function onBack(fn: (e: BackEvent) => void) {
|
|
207
|
-
backHandlers.
|
|
208
|
-
let cleanup = () =>
|
|
220
|
+
backHandlers.push(fn)
|
|
221
|
+
let cleanup = () => {
|
|
222
|
+
let i = backHandlers.lastIndexOf(fn)
|
|
223
|
+
if (i >= 0) backHandlers.splice(i, 1)
|
|
224
|
+
}
|
|
209
225
|
onCleanup(cleanup)
|
|
210
226
|
return cleanup
|
|
211
227
|
}
|
|
@@ -338,7 +354,9 @@ export function attachWindow(_nodeId: number) {
|
|
|
338
354
|
},
|
|
339
355
|
}
|
|
340
356
|
// Copy first: a handler may unregister (itself or others) mid-dispatch.
|
|
341
|
-
|
|
357
|
+
// Top of the stack down, stopping as soon as one takes the event.
|
|
358
|
+
let stack = [...backHandlers]
|
|
359
|
+
for (let i = stack.length - 1; i >= 0 && !prevented; i--) stack[i]!(e)
|
|
342
360
|
if (!prevented) exit()
|
|
343
361
|
})
|
|
344
362
|
|