@solidrt/core 0.0.43 → 0.0.45

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.
@@ -8,6 +8,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
8
8
  ## Host elements and layout
9
9
  - `window-root.tsx` - the minimal app; the root must be `<window>`.
10
10
  - `view-layout.tsx` - `<view>` as a flex container; containers do not paint.
11
+ - `view-viewbox.tsx` - `viewBox` on a `<view>`: author a scene once in fixed design units and let the view uniformly scale-and-center (letterbox) that space into its box. A pure fit transform - it never sizes the element (layout still does); children live in design space (the box they inherit IS the design size, so a bare `d-rect` fills it); pointer `localX`/`localY` arrive in design units. The fixed-aspect alternative to `windowSizeClass` reflow for diagrams, slides, dashboards, game boards.
11
12
  - `background-rect.tsx` - a `d-rect` filling its parent as a background.
12
13
  - `detached-positioning.tsx` - the `d-` prefix: x/y placement, no reflow, detached-only children.
13
14
  - `text-paint-styling.tsx` - the uniform `color` prop; `drawStyle="stroke"` vs fill.
@@ -27,7 +28,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
27
28
 
28
29
  ## Window state
29
30
  - `window-signals.tsx` - reactive `windowSize()` / `safeArea()` accessors (prefer over `onResize`).
30
- - `responsive-grid.tsx` - one app across phone/tablet/desktop: `capabilities.windowSizeClass` (Material 3 breakpoints, a reactive getter) drives the column count and `windowSize()` sizes each card; reflows on resize.
31
+ - `responsive-grid.tsx` - one app across phone/tablet/desktop: `capabilities.windowSizeClass` (Material 3 breakpoints, a reactive getter) drives the column count and `windowSize()` sizes each card; reflows on resize. The reflow answer; for fixed-aspect content use `view-viewbox.tsx` instead.
31
32
 
32
33
  ## Overlays
33
34
  - `portal.tsx` - `createPortal` relocating content to the window root to escape clipping.
@@ -0,0 +1,110 @@
1
+ // A draw target: one render target holding an ordered, mutable LIST of
2
+ // draws - two orbiting triangles from two different programs sharing one
3
+ // depth buffer (they occlude each other correctly as they cross), plus a
4
+ // third entry added and removed on a timer. One render of the target is one
5
+ // GPU pass no matter how many entries it holds, and per-entry params
6
+ // (setDrawParams) are the per-object channel: each triangle carries its own
7
+ // angle uniform, the model-matrix pattern at toy scale.
8
+ //
9
+ // The depth split to notice: the TARGET owns the storage (depth: true on
10
+ // createDrawTarget - what makes cross-entry occlusion work), each PIPELINE
11
+ // owns the behavior (depth: true on createRenderPipeline - this draw tests
12
+ // and writes). A depth-testing pipeline added to a depthless draw target
13
+ // throws at addDraw.
14
+ import { render, onFrame } from "@solidrt/core"
15
+ import {
16
+ addDraw,
17
+ compileShader,
18
+ createBuffer,
19
+ createDrawTarget,
20
+ createRenderPipeline,
21
+ glsl,
22
+ linkProgram,
23
+ removeDraw,
24
+ setDrawParams,
25
+ } from "@solidrt/core/gpu"
26
+ import type { DrawId } from "@solidrt/core/gpu"
27
+
28
+ let VERTEX = glsl`
29
+ in vec2 aPos;
30
+ uniform float uAngle;
31
+
32
+ void main() {
33
+ // Orbit in x, swing through depth in z: the two entries cross each
34
+ // other, and the shared depth buffer decides who is in front.
35
+ float c = cos(uAngle), s = sin(uAngle);
36
+ vec2 p = aPos * 0.55 + vec2(0.55 * c, 0.0);
37
+ gl_Position = vec4(p, 0.5 * s, 1.0);
38
+ }
39
+ `
40
+
41
+ let FRAGMENT_WARM = glsl`
42
+ void main() {
43
+ fragColor = vec4(0.95, 0.45, 0.2, 1.0);
44
+ }
45
+ `
46
+
47
+ let FRAGMENT_COOL = glsl`
48
+ void main() {
49
+ fragColor = vec4(0.25, 0.55, 0.95, 1.0);
50
+ }
51
+ `
52
+
53
+ let FRAGMENT_PULSE = glsl`
54
+ uniform float uPhase;
55
+ void main() {
56
+ fragColor = vec4(vec3(0.6 + 0.4 * sin(uPhase)), 1.0);
57
+ }
58
+ `
59
+
60
+ function App() {
61
+ let triangle = createBuffer(new Float32Array([0, 0.6, -0.5, -0.4, 0.5, -0.4]), { label: "tri" })
62
+ let vs = compileShader("vertex", VERTEX, { header: true })
63
+ let attrs = [{ name: "aPos", format: "vec2" as const }]
64
+ let warm = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_WARM, { header: true })), {
65
+ attributes: attrs,
66
+ depth: true,
67
+ })
68
+ let cool = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_COOL, { header: true })), {
69
+ attributes: attrs,
70
+ depth: true,
71
+ })
72
+ let pulse = createRenderPipeline(linkProgram(vs, compileShader("fragment", FRAGMENT_PULSE, { header: true })), {
73
+ attributes: attrs,
74
+ })
75
+
76
+ let target = createDrawTarget(512, 512, { depth: true, clearColor: [0.04, 0.04, 0.08, 1], label: "orbits" })
77
+ let warmDraw = addDraw(target, warm, { uAngle: 0 }, { buffer: triangle })
78
+ let coolDraw = addDraw(target, cool, { uAngle: Math.PI }, { buffer: triangle })
79
+
80
+ // A third entry blinks in and out every second: add/remove are ordinary
81
+ // per-frame-affordable writes, and a removed DrawId simply retires. Its
82
+ // pipeline declares no depth, so it neither tests nor writes: list order
83
+ // alone places it, and inserting it BEFORE the warm triangle
84
+ // (before: warmDraw) keeps the flash beneath both orbits - appended
85
+ // instead, it would cover them. setDrawOrder is the wholesale form when a
86
+ // scene sorts its whole list.
87
+ let pulseDraw: DrawId | null = null
88
+ onFrame((tick) => {
89
+ let t = tick / 1000
90
+ setDrawParams(target, warmDraw, { uAngle: t })
91
+ setDrawParams(target, coolDraw, { uAngle: t + Math.PI })
92
+ let wantPulse = Math.floor(t) % 2 === 0
93
+ if (wantPulse && pulseDraw === null) {
94
+ pulseDraw = addDraw(target, pulse, { uAngle: t * 0.3, uPhase: t * 5 }, { buffer: triangle, before: warmDraw })
95
+ } else if (!wantPulse && pulseDraw !== null) {
96
+ removeDraw(target, pulseDraw)
97
+ pulseDraw = null
98
+ } else if (pulseDraw !== null) {
99
+ setDrawParams(target, pulseDraw, { uAngle: t * 0.3, uPhase: t * 5 })
100
+ }
101
+ })
102
+
103
+ return (
104
+ <window alignItems="center" justifyContent="center">
105
+ <texture src={target} width={420} height={420} />
106
+ </window>
107
+ )
108
+ }
109
+
110
+ render(() => <App />)
@@ -1,15 +1,18 @@
1
- // Instanced drawing: one 3-vertex triangle in the buffer, drawn hundreds of
2
- // times with instanceCount (glDrawArraysInstanced, native ES 3.0). The vertex
3
- // stage tells copies apart by gl_InstanceID - here each instance becomes one
4
- // petal on a phyllotaxis spiral, placed and tinted from its index alone, so
5
- // the geometry buffer stays 6 floats no matter the population.
1
+ // Instanced drawing with per-instance attributes: one 3-vertex triangle in
2
+ // the vertex buffer, drawn hundreds of times, each copy reading its own
3
+ // record from an instance buffer (instanceAttributes on the pipeline +
4
+ // instanceBuffer on the target, vertex divisor 1 underneath). Here each
5
+ // record is one petal of a phyllotaxis spiral - its angle, radius, size, and
6
+ // tint, computed once in JS - so the vertex stage just reads state instead
7
+ // of re-deriving it from gl_InstanceID every frame, and the geometry buffer
8
+ // stays 6 floats no matter the population.
6
9
  //
7
10
  // The draw range is data: setDraw merges partial updates like params, so the
8
11
  // per-frame write below changes only instanceCount while firstVertex and
9
- // vertexCount keep their values (the whole buffer). instanceCount 0 would
10
- // draw nothing - the population legitimately breathes down to a single
11
- // petal and could go dark the same way. gl_InstanceID always counts from 0
12
- // (ES 3.0 has no base instance).
12
+ // vertexCount keep their values (the whole buffer). With an instance buffer
13
+ // bound, an omitted instanceCount would default to one instance per record
14
+ // (the whole population); the explicit 1 here starts the bloom closed, and
15
+ // the count is bounds-checked against the records either way.
13
16
  import { render, onFrame, createSignal } from "@solidrt/core"
14
17
  import { createBuffer, createPipelineTexture, glsl, setDraw } from "@solidrt/core/gpu"
15
18
 
@@ -17,21 +20,20 @@ let MAX_PETALS = 324
17
20
 
18
21
  let VERTEX = glsl`
19
22
  in vec2 aPos;
23
+ in float iAngle;
24
+ in float iRadius;
25
+ in float iScale;
26
+ in vec3 iTint;
20
27
  out vec3 vTint;
21
28
  uniform float uTime;
22
29
 
23
30
  void main() {
24
- // One instance = one petal: radius grows with sqrt(index) and the angle
25
- // steps by the golden angle (~2.39996 rad) - the sunflower layout.
26
- float i = float(gl_InstanceID);
27
- float angle = i * 2.39996 + uTime * 0.3;
28
- float radius = 0.045 * sqrt(i);
29
- // Petals shrink toward the rim and point outward along the spiral.
30
- float scale = mix(0.05, 0.016, sqrt(i) / 18.0);
31
+ // The record places the petal; time only spins the whole flower.
32
+ float angle = iAngle + uTime * 0.3;
31
33
  float c = cos(angle), s = sin(angle);
32
- vec2 p = radius * vec2(c, s) + mat2(c, s, -s, c) * (aPos * scale);
34
+ vec2 p = iRadius * vec2(c, s) + mat2(c, s, -s, c) * (aPos * iScale);
33
35
  gl_Position = vec4(p, 0.0, 1.0);
34
- vTint = 0.5 + 0.5 * cos(6.2832 * (i / 96.0) + vec3(0.0, 2.1, 4.2));
36
+ vTint = iTint;
35
37
  }
36
38
  `
37
39
 
@@ -43,13 +45,40 @@ let FRAGMENT = glsl`
43
45
  }
44
46
  `
45
47
 
48
+ // One record per petal (matching instanceAttributes: 6 floats): radius grows
49
+ // with sqrt(index), the angle steps by the golden angle (~2.39996 rad) - the
50
+ // sunflower layout - petals shrink toward the rim, and the tint walks a
51
+ // cosine palette.
52
+ function petalRecords() {
53
+ let records = new Float32Array(MAX_PETALS * 6)
54
+ for (let i = 0; i < MAX_PETALS; i++) {
55
+ let at = i * 6
56
+ let t = Math.sqrt(i) / 18
57
+ records[at] = i * 2.39996
58
+ records[at + 1] = 0.045 * Math.sqrt(i)
59
+ records[at + 2] = 0.05 * (1 - t) + 0.016 * t
60
+ for (let k = 0; k < 3; k++) {
61
+ records[at + 3 + k] = 0.5 + 0.5 * Math.cos(6.2832 * (i / 96) + k * 2.1)
62
+ }
63
+ }
64
+ return records
65
+ }
66
+
46
67
  function App() {
47
68
  // The whole mesh: one triangle, reused by every instance.
48
69
  let bufferId = createBuffer(new Float32Array([0, 1.3, -1, -0.75, 1, -0.75]), { label: "petal-tri" })
70
+ let instanceId = createBuffer(petalRecords(), { label: "petal-records" })
49
71
  let id = createPipelineTexture(VERTEX, FRAGMENT, 512, 512, { uTime: 0 }, {
50
72
  label: "petals",
51
73
  attributes: [{ name: "aPos", format: "vec2" }],
52
74
  buffer: bufferId,
75
+ instanceAttributes: [
76
+ { name: "iAngle", format: "f32" },
77
+ { name: "iRadius", format: "f32" },
78
+ { name: "iScale", format: "f32" },
79
+ { name: "iTint", format: "vec3" },
80
+ ],
81
+ instanceBuffer: instanceId,
53
82
  instanceCount: 1,
54
83
  clearColor: [0.03, 0.03, 0.06, 1],
55
84
  })
@@ -6,7 +6,11 @@
6
6
  // but no vUV, and app-driven uniforms (uTime below) are the source's own
7
7
  // declarations. Uniforms are driven exactly like createShaderTexture:
8
8
  // declaratively via the <texture> params prop, applied at the next repaint.
9
- import { render, onFrame, createSignal } from "@solidrt/core"
9
+ // The cube draws indexed (indexBuffer + indexFormat: 24 shared vertices
10
+ // stitched by 36 uint16 indices instead of 36 unshared vertices) with its
11
+ // back faces culled (cull: "back") - a closed mesh never shows them, so
12
+ // rastering them is pure waste.
13
+ import { render, onFrame, createSignal, pct } from "@solidrt/core"
10
14
  import { createBuffer, createPipelineTexture, glsl } from "@solidrt/core/gpu"
11
15
 
12
16
  let VERTEX = glsl`
@@ -21,15 +25,19 @@ let VERTEX = glsl`
21
25
  mat3 rotY = mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
22
26
  mat3 rotX = mat3(1.0, 0.0, 0.0, 0.0, cx, sx, 0.0, -sx, cx);
23
27
  vec3 p = rotX * (rotY * aPos);
24
- p.z += 2.5;
28
+ p.z -= 2.5;
25
29
 
26
- // Perspective projection (near 1, far 10). Clip y is negated: the target's
27
- // memory row 0 is clip y = -1, and Impeller samples row 0 as the top, so
28
- // camera-up needs the flip to be displayed up.
30
+ // Standard right-handed camera at the origin looking down -z, perspective
31
+ // near 1 far 10. Clip y is negated: the target's memory row 0 is clip
32
+ // y = -1, and Impeller samples row 0 as the top, so camera-up needs the
33
+ // flip to be displayed up. With this textbook rig the CCW-outward mesh
34
+ // winds counter-clockwise AS DISPLAYED on its camera-facing faces -
35
+ // exactly the front-face rule cull: "back" tests against.
36
+ float w = -p.z;
29
37
  float f = 2.0;
30
38
  float a = 11.0 / 9.0;
31
39
  float b = -20.0 / 9.0;
32
- gl_Position = vec4(p.x * f, -p.y * f, p.z * a + b, p.z);
40
+ gl_Position = vec4(p.x * f, -p.y * f, w * a + b, w);
33
41
  vColor = aColor;
34
42
  }
35
43
  `
@@ -41,12 +49,16 @@ let FRAGMENT = glsl`
41
49
  }
42
50
  `
43
51
 
44
- // Interleaved [pos vec3, color vec3], 6 faces x 2 triangles, one color per face.
45
- function cube(): Float32Array {
52
+ // Interleaved [pos vec3, color vec3]: 24 vertices, 4 per face with that
53
+ // face's color - each corner stored once and stitched into 2 triangles by
54
+ // the index buffer below, the sharing real meshes are made of. Every face
55
+ // winds counter-clockwise seen from outside, so with the projection's y
56
+ // negation the cube culls correctly with cull: "back".
57
+ function cubeVertices(): Float32Array {
46
58
  type Vec3 = [number, number, number]
47
59
  let verts: number[] = []
48
60
  let quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: Vec3) => {
49
- for (let p of [a, b, c, a, c, d]) verts.push(p[0], p[1], p[2], color[0], color[1], color[2])
61
+ for (let p of [a, b, c, d]) verts.push(p[0], p[1], p[2], color[0], color[1], color[2])
50
62
  }
51
63
  let s = 0.5
52
64
  quad([-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s], [0.9, 0.3, 0.3]) // front
@@ -58,26 +70,44 @@ function cube(): Float32Array {
58
70
  return new Float32Array(verts)
59
71
  }
60
72
 
73
+ // Two triangles per face over its 4 shared vertices: 36 uint16 indices.
74
+ function cubeIndices(): Uint16Array {
75
+ let indices: number[] = []
76
+ for (let face = 0; face < 6; face++) {
77
+ let v = face * 4
78
+ indices.push(v, v + 1, v + 2, v, v + 2, v + 3)
79
+ }
80
+ return new Uint16Array(indices)
81
+ }
82
+
61
83
  function App() {
62
- // Labels name the buffer and target in the dev tooling's GPU inventory
84
+ // Labels name the buffers and target in the dev tooling's GPU inventory
63
85
  // (and in engine log messages) - free-form, purely diagnostic.
64
- let bufferId = createBuffer(cube(), { label: "cube-verts" })
65
- let id = createPipelineTexture(VERTEX, FRAGMENT, 512, 512, { uTime: 0 }, {
86
+ let bufferId = createBuffer(cubeVertices(), { label: "cube-verts" })
87
+ let indexId = createBuffer(cubeIndices(), { label: "cube-indices" })
88
+ let id = createPipelineTexture(VERTEX, FRAGMENT, 1024, 1024, { uTime: 0 }, {
66
89
  label: "cube",
67
90
  attributes: [
68
91
  { name: "aPos", format: "vec3" },
69
92
  { name: "aColor", format: "vec3" },
70
93
  ],
71
94
  buffer: bufferId,
95
+ indexBuffer: indexId,
96
+ indexFormat: "uint16",
72
97
  depth: true,
98
+ cull: "back",
73
99
  clearColor: [0.08, 0.08, 0.12, 1],
74
100
  })
75
101
  let [time, setTime] = createSignal(0)
76
102
  onFrame((tick) => setTime(tick / 1000))
77
103
 
104
+ // Fill the window: the viewBox fits and centers the square content into
105
+ // the full-window view, so the projection is never stretched.
78
106
  return (
79
- <window alignItems="center" justifyContent="center">
80
- <texture src={id} params={{ uTime: time() }} width={400} height={400} />
107
+ <window>
108
+ <view width={pct(100)} height={pct(100)} viewBox={[1024, 1024]}>
109
+ <texture src={id} params={{ uTime: time() }} width={1024} height={1024} />
110
+ </view>
81
111
  </window>
82
112
  )
83
113
  }
@@ -8,6 +8,11 @@
8
8
  // functions - read `capabilities.windowSizeClass` (no call). Reading it inside
9
9
  // JSX tracks, so the memo below re-runs on every resize. `windowSize()` IS a
10
10
  // function (call it) - we read its width to size each card exactly.
11
+ //
12
+ // This is the REFLOW answer, for layouts that genuinely rearrange across form
13
+ // factors. For content with fixed internal geometry (diagrams, slides,
14
+ // dashboards, game boards) do not branch on window size at all: author one
15
+ // design space and let `viewBox` scale it to fit - see view-viewbox.tsx.
11
16
  import { render, capabilities, windowSize, createMemo, For } from "@solidrt/core"
12
17
 
13
18
  const GAP = 16
@@ -0,0 +1,85 @@
1
+ // Source history on a boundary shader: previous: true retains the prior
2
+ // rasterization of the subtree as uPrevious, rotated when the content
3
+ // actually changes - not per frame. That makes it transition material: on
4
+ // each content change uPrevious holds the old look and uSource the new, and
5
+ // uMix sweeps a cross-dissolve between them. For a static panel uPrevious
6
+ // equals uSource (the previous rasterization IS the same content), so
7
+ // feedback/accumulation is not what this is for - that stays with manual
8
+ // targets.
9
+ //
10
+ // Click the panel to cycle its colors: the old palette dissolves into the
11
+ // new one instead of snapping.
12
+ import { render, onFrame, createSignal } from "@solidrt/core"
13
+ import { compileShader, destroyShader, glsl, linkProgram } from "@solidrt/core/gpu"
14
+
15
+ let VERTEX = glsl`
16
+ out vec2 vUV;
17
+ void main() {
18
+ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
19
+ vUV = p;
20
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
21
+ }
22
+ `
23
+
24
+ let DISSOLVE = glsl`
25
+ uniform sampler2D uSource;
26
+ uniform sampler2D uPrevious;
27
+ uniform float uMix;
28
+ in vec2 vUV;
29
+ void main() {
30
+ fragColor = mix(texture(uPrevious, vUV), texture(uSource, vUV), uMix);
31
+ }
32
+ `
33
+
34
+ const PALETTES = [
35
+ ["#0077ff", "#ff6a00", "#00c46a"],
36
+ ["#e63946", "#ffb703", "#8338ec"],
37
+ ["#00b4d8", "#ef476f", "#ffd166"],
38
+ ] as const
39
+
40
+ function App() {
41
+ let vs = compileShader("vertex", VERTEX, { header: true })
42
+ let fs = compileShader("fragment", DISSOLVE, { header: true })
43
+ let dissolve = linkProgram(vs, fs, { label: "dissolve" })
44
+ destroyShader(vs)
45
+ destroyShader(fs)
46
+
47
+ let [palette, setPalette] = createSignal(0)
48
+ let colors = () => PALETTES[palette() % PALETTES.length]!
49
+ let [mixv, setMix] = createSignal(1)
50
+ // Sweep uMix back to 1 after each change (~250ms). At 1 the signal stops
51
+ // changing, so the pass stops re-running.
52
+ onFrame((_tick, _frame, rate) => setMix(m => Math.min(1, m + 4 / rate)))
53
+
54
+ let cycle = () => {
55
+ setPalette(p => (p + 1) % PALETTES.length)
56
+ setMix(0)
57
+ }
58
+
59
+ return (
60
+ <window alignItems="center" justifyContent="center">
61
+ <view
62
+ repaintBoundary="snapshot"
63
+ shader={{ program: dissolve, params: { uMix: mixv() }, previous: true }}
64
+ onPointerDown={cycle}
65
+ flexDirection="column"
66
+ gap={12}
67
+ alignItems="center"
68
+ justifyContent="center"
69
+ width={360}
70
+ height={240}
71
+ >
72
+ <rect position="absolute" width="100%" height="100%" radius={16} color="#dde3ec" />
73
+ <text fontSize={24} color="#222">Boundary history</text>
74
+ <view flexDirection="row" gap={12}>
75
+ <rect width={70} height={70} radius={12} color={colors()[0]} />
76
+ <rect width={70} height={70} radius={12} color={colors()[1]} />
77
+ <rect width={70} height={70} radius={12} color={colors()[2]} />
78
+ </view>
79
+ <text fontSize={13} color="#666">Click: colors cross-dissolve</text>
80
+ </view>
81
+ </window>
82
+ )
83
+ }
84
+
85
+ render(() => <App />)
@@ -0,0 +1,81 @@
1
+ // A boundary shader: a view with repaintBoundary="snapshot" runs its
2
+ // rasterized subtree through a linked program and composites the result in
3
+ // its place. The program contract matches shader targets, not the window
4
+ // pass: uSource is the subtree's rasterization (top-left origin, so vUV
5
+ // needs no flip), iResolution is the boundary in physical pixels. The pass
6
+ // is split from content invalidation: animating params here re-runs only
7
+ // the pass - the panel's content is rasterized once and stays cached.
8
+ //
9
+ // outset adds a transparent margin the effect may write into: without it the
10
+ // wave clips at the layout box, with it the crests escape the box edge.
11
+ //
12
+ // Click the panel to toggle the warp between 0 and 1: at 0 the program is an
13
+ // identity pass, which must be indistinguishable from the plain snapshot.
14
+ import { render, onFrame, createSignal } from "@solidrt/core"
15
+ import { compileShader, destroyShader, glsl, linkProgram } from "@solidrt/core/gpu"
16
+
17
+ // { header: true } supplies #version and precision; the varyings are the
18
+ // program's own. Unlike the window pass there is no flip here: a target
19
+ // pass's vUV origin already matches the sampled texture's top-left.
20
+ let VERTEX = glsl`
21
+ out vec2 vUV;
22
+ void main() {
23
+ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
24
+ vUV = p;
25
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
26
+ }
27
+ `
28
+
29
+ let WARP = glsl`
30
+ uniform sampler2D uSource;
31
+ uniform float uAmount;
32
+ uniform float uTime;
33
+ in vec2 vUV;
34
+ void main() {
35
+ vec2 uv = vUV;
36
+ uv.x += sin(uv.y * 24.0 + uTime * 3.0) * 0.03 * uAmount;
37
+ uv.y += sin(uv.x * 18.0 - uTime * 2.0) * 0.03 * uAmount;
38
+ fragColor = texture(uSource, uv);
39
+ }
40
+ `
41
+
42
+ function App() {
43
+ let vs = compileShader("vertex", VERTEX, { header: true })
44
+ let fs = compileShader("fragment", WARP, { header: true })
45
+ let warp = linkProgram(vs, fs, { label: "panel-warp" })
46
+ destroyShader(vs)
47
+ destroyShader(fs)
48
+
49
+ let [time, setTime] = createSignal(0)
50
+ let [amount, setAmount] = createSignal(1)
51
+ onFrame(tick => setTime(tick / 1000))
52
+
53
+ return (
54
+ <window alignItems="center" justifyContent="center">
55
+ <view
56
+ repaintBoundary="snapshot"
57
+ shader={{ program: warp, params: { uTime: time(), uAmount: amount() }, outset: 16 }}
58
+ onPointerDown={() => setAmount(a => (a > 0 ? 0 : 1))}
59
+ flexDirection="column"
60
+ gap={12}
61
+ alignItems="center"
62
+ justifyContent="center"
63
+ width={360}
64
+ height={240}
65
+ >
66
+ {/* Fills the boundary, so the box edge is a content edge: the warp
67
+ visibly ripples it out into the outset margin. */}
68
+ <rect position="absolute" width="100%" height="100%" radius={16} color="#dde3ec" />
69
+ <text fontSize={24} color="#222">Boundary shader</text>
70
+ <view flexDirection="row" gap={12}>
71
+ <rect width={70} height={70} radius={12} color="#0077ff" />
72
+ <rect width={70} height={70} radius={12} color="#ff6a00" />
73
+ <rect width={70} height={70} radius={12} color="#00c46a" />
74
+ </view>
75
+ <text fontSize={13} color="#666">Click to toggle warp (identity at 0)</text>
76
+ </view>
77
+ </window>
78
+ )
79
+ }
80
+
81
+ render(() => <App />)
@@ -0,0 +1,86 @@
1
+ // `viewBox` on a <view> is the fixed-aspect answer to "many screen sizes": you
2
+ // author the whole scene once, in your own made-up design units, and the view
3
+ // scales that space to fit its box. It is SVG's viewBox generalized to any
4
+ // subtree, not an SVG-only thing.
5
+ //
6
+ // Four facts, each demonstrated below:
7
+ // 1. It NEVER sizes the element. `viewBox` is a pure fit transform; the box
8
+ // still takes its size from layout (here `flex={1}`). A view with only a
9
+ // viewBox and no layout size is zero-sized and paints nothing.
10
+ // 2. It fits UNIFORMLY and centers - one scale for both axes, SVG's default
11
+ // preserveAspectRatio. Content never stretches; the leftover on the loose
12
+ // axis is letterbox, showing the window background through it.
13
+ // 3. Children live in DESIGN space. Their x/y, w/h, fontSize, stroke widths -
14
+ // everything resolves against the design size, not the box. The box a child
15
+ // inherits IS the design size, so a bare `d-rect` fills the design space
16
+ // and detached text wraps at its width.
17
+ // 4. Pointer coordinates arrive in design space too. localX/localY on the
18
+ // viewBox view (and on anything under it) read in design units, so no
19
+ // scale factor is threaded through the app's hit math.
20
+ //
21
+ // The payoff: no `windowSizeClass` branching, no per-breakpoint sizes, no
22
+ // scale factor anywhere. The same code runs unchanged from a desktop window to
23
+ // a phone. Reach for reflow (responsive-grid.tsx) only when the layout
24
+ // genuinely rearranges across form factors; for content with fixed internal
25
+ // geometry - diagrams, slides, dashboards, game boards, emulator screens - fit
26
+ // one design space instead.
27
+ //
28
+ // Resize the window and watch: the scene keeps its aspect ratio, everything
29
+ // including text scales together, and the readout reports the same design
30
+ // coordinates for the same spot on the scene at any window size.
31
+ import { render, createSignal, Show } from "@solidrt/core"
32
+
33
+ // The design space. Every number below is in these units - invent whatever
34
+ // suits the content and stay in it. 640x400 is 16:10.
35
+ const DESIGN_W = 640
36
+ const DESIGN_H = 400
37
+
38
+ function App() {
39
+ let [at, setAt] = createSignal<{ x: number; y: number } | null>(null)
40
+ let round = (v: number) => Math.round(v)
41
+
42
+ return (
43
+ // The window background is what shows through the letterbox bars.
44
+ <window>
45
+ <d-rect color="#0b0f17" />
46
+
47
+ {/* flex={1} sizes the box; viewBox only maps content into it (fact 1). */}
48
+ <view
49
+ flex={1}
50
+ viewBox={[DESIGN_W, DESIGN_H]}
51
+ onPointerMove={(e) => setAt({ x: e.localX, y: e.localY })}
52
+ onPointerLeave={() => setAt(null)}
53
+ >
54
+ {/* No w/h, so it fills the box it inherits - which under a viewBox is
55
+ the design space (fact 3). Its edges are the letterbox edges. */}
56
+ <d-rect color="#151b28" />
57
+
58
+ {/* A scene at literal design coordinates. No breakpoints, no
59
+ windowSize() reads, no scale factor: authored once, at this size. */}
60
+ <d-rect x={40} y={40} w={240} h={140} radius={12} color="#1f6feb" />
61
+ <d-rect x={300} y={40} w={300} h={140} radius={12} color="#3fb950" />
62
+ <d-oval x={40} y={220} w={140} h={140} color="#a371f7" />
63
+ <d-line x1={200} y1={300} x2={600} y2={300} color="#e3b341" strokeWidth={6} />
64
+ <d-text x={64} y={95} fontSize={28} color="#e6e6e6">
65
+ fixed design space
66
+ </d-text>
67
+ <d-text x={324} y={95} fontSize={28} color="#0b0f17">
68
+ {DESIGN_W} x {DESIGN_H}
69
+ </d-text>
70
+
71
+ {/* Pointer position in design units (fact 4), drawn in design units.
72
+ Non-keyed Show keeps the marker node mounted across moves (a
73
+ ternary on at() would recreate it every event); the accessor
74
+ reads update its props fine-grained. */}
75
+ <d-text x={200} y={330} fontSize={22} color="#8b949e">
76
+ {at() ? `design x ${round(at()!.x)}, y ${round(at()!.y)}` : "move the pointer over the scene"}
77
+ </d-text>
78
+ <Show when={at()}>
79
+ {(a) => <d-oval x={a().x - 8} y={a().y - 8} w={16} h={16} color="#f85149" />}
80
+ </Show>
81
+ </view>
82
+ </window>
83
+ )
84
+ }
85
+
86
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.43",
3
+ "version": "0.0.45",
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.43"
30
+ "@solidrt/flux-types": "0.0.45"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@solidjs/signals": "2.0.0-beta.26",
package/src/gpu.ts CHANGED
@@ -71,7 +71,7 @@ export type { TextureFormat } from "flux:gpu"
71
71
  // runtime, distinct types to the checker, so a cross-space slip like
72
72
  // destroyBuffer(textureId) fails to compile. Exported so apps can annotate
73
73
  // storage (`let ids: TextureId[]`).
74
- export type { BufferId, ProgramId, RenderPipelineId, ShaderStageId, TextureId } from "flux:gpu"
74
+ export type { BufferId, DrawId, ProgramId, RenderPipelineId, ShaderStageId, TextureId } from "flux:gpu"
75
75
 
76
76
  // Re-exported so callers that depend on @solidrt/core -- like @solidrt/components
77
77
  // -- need not import flux directly: destroyTexture for the manual-cleanup path
@@ -108,7 +108,20 @@ export {
108
108
  // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
109
109
  // ping-pong buffer, reset state to a known image.
110
110
  export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
111
- export type { BlendMode, DrawRange, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
111
+ export type { BlendMode, CullMode, DrawRange, IndexBinding, IndexFormat, IndexRange, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
112
+
113
+ // The draw-list verbs, re-exported raw: entries live and die with their draw
114
+ // target (see createDrawTarget below), so there is no per-entry lifetime to
115
+ // wrap. addDraw adds an entry (appended, or inserted via opts.before) and
116
+ // returns its stable DrawId; removeDraw drops one; setDrawParams /
117
+ // setDrawTextures / setDrawRange are the per-entry forms of setShaderParams /
118
+ // setShaderTextures / setDraw, taking (target, draw, value) with identical
119
+ // merge and validation semantics. The per-object hot path is setDrawParams (a
120
+ // moved mesh = one call with its new matrix). setDrawOrder replaces the whole
121
+ // list order with a full permutation of the live ids - the sorting verb
122
+ // (opaque front-to-back, transparent back-to-front, re-issued when the
123
+ // camera moves).
124
+ export { addDraw, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setDrawTextures } from "flux:gpu"
112
125
 
113
126
  // The device ceilings (max texture/target size, sampler inputs per pass,
114
127
  // vertex attributes per pipeline), queried once at startup. Creates and binds
@@ -269,15 +282,20 @@ export function createShaderTexture(
269
282
  * uniforms with `<texture params>` or `setShaderParams`). Many targets may
270
283
  * share one pipeline, and creating a target compiles nothing. The target
271
284
  * brings the per-target half: size, the concrete vertex `buffer` the
272
- * pipeline's attribute layout describes, the draw range (`vertexCount`
273
- * defaults to the rest of the buffer from `firstVertex` on, `instanceCount`
274
- * repeats it as instances told apart by `gl_InstanceID`; a fullscreen pass
275
- * over an attributeless pipeline is `{ vertexCount: 3 }` with a
276
- * covering-triangle vertex stage), uniforms, and
277
- * `clearColor`. Draw state (`attributes`, `topology`, `blend`, `depth`,
278
- * `depthWrite`) lives on the pipeline and throws here. Frees the target when
279
- * the reactive owner is disposed (opt out with `opts.manual`); the pipeline
280
- * is yours and outlives it.
285
+ * pipeline's attribute layout describes, the `instanceBuffer` its
286
+ * `instanceAttributes` describe (required exactly when it declares any),
287
+ * the draw range (`vertexCount` defaults to the rest of the buffer from
288
+ * `firstVertex` on, `instanceCount` repeats it as instances told apart by
289
+ * `gl_InstanceID` and defaults to one per instance-buffer record; a
290
+ * fullscreen pass over an attributeless pipeline is `{ vertexCount: 3 }`
291
+ * with a covering-triangle vertex stage), uniforms, and
292
+ * `clearColor`. An `indexBuffer` + `indexFormat` pair makes the draw indexed
293
+ * (shared vertices stored once), with the range in `firstIndex`/`indexCount`
294
+ * spelling - see IndexBinding/IndexRange. Draw state (`attributes`,
295
+ * `instanceAttributes`, `topology`, `blend`, `cull`, `depth`, `depthWrite`)
296
+ * lives on the pipeline
297
+ * and throws here. Frees the target when the reactive owner is disposed (opt
298
+ * out with `opts.manual`); the pipeline is yours and outlives it.
281
299
  *
282
300
  * `render: "manual"` makes it a manual target: the runtime never renders it
283
301
  * (it starts cleared to `clearColor`), only an explicit `renderTarget(id)`
@@ -297,10 +315,11 @@ export function createShaderTarget(
297
315
  opts?: {
298
316
  textures?: Record<string, gpu.TextureId>
299
317
  buffer?: gpu.BufferId
318
+ instanceBuffer?: gpu.BufferId
300
319
  clearColor?: [number, number, number, number]
301
320
  render?: "auto" | "manual"
302
321
  loadOp?: "clear" | "load"
303
- } & gpu.DrawRange &
322
+ } & (gpu.DrawRange | (gpu.IndexBinding & gpu.IndexRange)) &
304
323
  CreateOptions &
305
324
  SamplerOptions,
306
325
  ): gpu.TextureId {
@@ -309,6 +328,42 @@ export function createShaderTarget(
309
328
  return id
310
329
  }
311
330
 
331
+ /**
332
+ * Creates a draw target: a render target holding an ordered, MUTABLE list of
333
+ * draws, rendered as one pass - clear once (color, and depth when declared),
334
+ * then every entry in list order into the same storage. This is the
335
+ * multi-pass primitive (N meshes x N pipelines sharing one depth buffer -
336
+ * what every 3D API calls a render pass), retained: build the list with
337
+ * `addDraw`, prune it with `removeDraw`, and drive per-entry state with
338
+ * `setDrawParams` / `setDrawTextures` / `setDrawRange`. `depth: true` gives
339
+ * the target the depth storage all entries share (cross-entry occlusion);
340
+ * whether an entry tests/writes it stays pipeline state, and a depth-testing
341
+ * pipeline into a depthless target throws at `addDraw`.
342
+ *
343
+ * The render contract is unchanged: the list is input data, so an ordinary
344
+ * (`render: "auto"`) draw target re-renders exactly when its entries or
345
+ * their inputs change - a static scene costs zero passes, and one render is
346
+ * one pass regardless of entry count. `render: "manual"` and `loadOp` work
347
+ * as on `createShaderTarget`. Returns the texture id; frees on owner
348
+ * disposal (opt out with `opts.manual`), taking its entries with it - the
349
+ * entries' pipelines and buffers are yours and outlive it.
350
+ */
351
+ export function createDrawTarget(
352
+ width: number,
353
+ height: number,
354
+ opts?: {
355
+ depth?: boolean
356
+ clearColor?: [number, number, number, number]
357
+ render?: "auto" | "manual"
358
+ loadOp?: "clear" | "load"
359
+ } & CreateOptions &
360
+ SamplerOptions,
361
+ ): gpu.TextureId {
362
+ let id = gpu.createDrawTarget(width, height, opts)
363
+ if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
364
+ return id
365
+ }
366
+
312
367
  /** The reactive shader description `createShaderTextureMemo` builds from.
313
368
  * Sampling (`filter`/`wrap`) is creation-time state, so changing it rebuilds
314
369
  * at a fresh id, like a fragment-source or sampler-binding change. */
@@ -439,7 +494,14 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
439
494
  * see DrawRange) defaults to the whole buffer drawn once and can be changed
440
495
  * later with `setDraw`; `instanceCount` is the standard answer to particles
441
496
  * and repeated meshes, N copies of the range told apart by `gl_InstanceID`
442
- * in the vertex stage. `opts.render: "manual"` and
497
+ * in the vertex stage. `opts.instanceAttributes` + `opts.instanceBuffer`
498
+ * (declare both or neither) give each instance its own interleaved record -
499
+ * real per-instance state instead of `gl_InstanceID` arithmetic - and
500
+ * `instanceCount` then defaults to one instance per record. An
501
+ * `indexBuffer` + `indexFormat` pair makes the draw
502
+ * indexed (shared vertices stored once), with the range in
503
+ * `firstIndex`/`indexCount` spelling; `opts.cull` discards one face set by
504
+ * winding (counter-clockwise as displayed = front). `opts.render: "manual"` and
443
505
  * `opts.loadOp` behave exactly as on {@link createShaderTarget}: step the
444
506
  * target with `renderTarget(id)`, and `loadOp: "load"` (manual-only) keeps
445
507
  * the previous contents under each draw. Frees the texture and GL program when the reactive
@@ -456,14 +518,17 @@ export function createPipelineTexture(
456
518
  textures?: Record<string, gpu.TextureId>
457
519
  attributes?: gpu.VertexAttribute[]
458
520
  buffer?: gpu.BufferId
521
+ instanceAttributes?: gpu.VertexAttribute[]
522
+ instanceBuffer?: gpu.BufferId
459
523
  topology?: gpu.Topology
460
524
  depth?: boolean
461
525
  depthWrite?: boolean
462
526
  blend?: gpu.BlendMode
527
+ cull?: gpu.CullMode
463
528
  clearColor?: [number, number, number, number]
464
529
  render?: "auto" | "manual"
465
530
  loadOp?: "clear" | "load"
466
- } & gpu.DrawRange &
531
+ } & (gpu.DrawRange | (gpu.IndexBinding & gpu.IndexRange)) &
467
532
  CreateOptions &
468
533
  SamplerOptions,
469
534
  ): gpu.TextureId {
package/src/types.d.ts CHANGED
@@ -447,6 +447,67 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
447
447
  * axis-aligned rects look identical, so prefer it for plain UI panels.
448
448
  */
449
449
  repaintBoundary?: boolean | "snapshot" | "snapshot-no-aa"
450
+ /**
451
+ * Run this view's rasterized subtree through a GPU program and composite
452
+ * the result in its place. Requires repaintBoundary="snapshot" (the cost
453
+ * is snapshot semantics, kept explicit; declared without it the shader is
454
+ * ignored with a warning). The pass is region-sized and split from content
455
+ * invalidation: a params-only change re-runs just the pass against the
456
+ * cached snapshot, so animating an effect over a static subtree never
457
+ * re-rasterizes it.
458
+ *
459
+ * The effect samples only the subtree's own pixels - grading, warping or
460
+ * dissolving the panel works; anything needing what is behind it does not.
461
+ * Sampling outside the content clamps to the edge, and the output is
462
+ * cropped to the layout box like any snapshot. Hit-testing stays on layout
463
+ * geometry: a distortion moves pixels, not hit targets. The view's own
464
+ * transform and opacity apply after the effect, so the program sees
465
+ * unrotated, opaque content.
466
+ */
467
+ shader?: ViewShaderProps | null
468
+ }
469
+
470
+ /**
471
+ * A boundary shader declaration. The program contract matches shader targets,
472
+ * not the window pass: the subtree's rasterization binds as
473
+ * `uniform sampler2D uSource` (top-left origin, like every sampled texture)
474
+ * and the pass draws the covering triangle attributeless. `iResolution`,
475
+ * filled by name, is the boundary in physical pixels.
476
+ */
477
+ export interface ViewShaderProps {
478
+ /** Linked program handle from linkProgram. */
479
+ program: ProgramId
480
+ /**
481
+ * Uniforms filled by name, paced to the next real repaint. A number drives
482
+ * a scalar (`float`/`int`); a flat number array drives the declared GLSL
483
+ * type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
484
+ */
485
+ params?: Record<string, number | number[]>
486
+ /** Extra sampler2D inputs: uniform name to texture id. */
487
+ textures?: Record<string, TextureId>
488
+ /**
489
+ * Transparent margin in logical px on every side of the layout box, for
490
+ * the effect to write into - glow, drop shadow, blur that bleeds past the
491
+ * edge. Grows the rasterized canvas and the composited quad; the subtree's
492
+ * own paint stays clipped to the layout box either way. The pass sees only
493
+ * the bigger iResolution - declare an app uniform if the program needs the
494
+ * margin size. Default 0.
495
+ */
496
+ outset?: number
497
+ /**
498
+ * Retain the prior rasterization of the subtree as
499
+ * `uniform sampler2D uPrevious`. Source history, not output history: it
500
+ * rotates when the content actually re-rasterizes, not per frame - on a
501
+ * content change uPrevious holds exactly the old look (transition
502
+ * material: cross-dissolve old into new), while for a static subtree with
503
+ * animated params uPrevious equals uSource. Feedback/accumulation is not
504
+ * this; that stays with manual targets. Costs one extra canvas-sized
505
+ * texture while declared; transparent until the first rotation, and reset
506
+ * to transparent by a size or scale change. Only declare the uPrevious
507
+ * uniform together with this flag - without it the uniform stays at unit 0
508
+ * and aliases uSource. Default false.
509
+ */
510
+ previous?: boolean
450
511
  }
451
512
 
452
513
  export interface ViewProps extends ViewOwnProps, LayoutProps {}