@solidrt/core 0.0.70 → 0.0.71

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.
@@ -102,7 +102,12 @@ Rules, in order of leverage:
102
102
  boundary node itself (or any ancestor) are hoisted out of the cache and
103
103
  applied at composite time, so animating x/y/scale/rotate/opacity of a
104
104
  boundary does NOT re-raster it (verified by A/B measurement - the damage
105
- system classifies these as Transform and keeps the node's own cache).
105
+ system classifies these as Compose and keeps the node's own cache). One
106
+ exception, for crispness: a boundary holding text that comes to rest at
107
+ a new scale re-records once there (a snapshot boundary re-rasterizes
108
+ once), so its content is rasterized at the resting size instead of
109
+ stretched, and a scale transition with a known end rasters for that end
110
+ as it starts; a press scale that returns to 1 never re-rasters.
106
111
  What DOES invalidate the cache is any paint or content change inside the
107
112
  subtree - colors, path data, text, a Show toggling - so drive animation
108
113
  with transforms and keep the cached content itself static. Off a boundary,
@@ -51,6 +51,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
51
51
  - `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.
52
52
  - `gpu-raw-program.tsx` - the raw shading layer: compileShader/linkProgram/createRenderPipeline/createShaderTarget, one vertex stage shared by two programs, with and without the standard header.
53
53
  - `gpu-pipeline.tsx` - `createPipelineTexture`: 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.
54
+ - `gpu-shader-chain.tsx` - a chain of targets: a plasma `createShaderTexture` bound as a cube `createPipelineTexture`'s sampler input (`textures: { uMap: plasmaId }`). Sampler bindings are live dependencies, so the app drives only the plasma's `uTime` and the cube re-renders after it every frame, in that order, with no uniform and no per-frame code of its own. The plasma shows beside the cube on the `<texture>` that carries its params.
54
55
  - `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.
55
56
  - `gpu-instancing.tsx` - instanced drawing: one 3-vertex triangle drawn hundreds of times via `instanceCount`, each instance placed and tinted on a phyllotaxis spiral from `gl_InstanceID` alone. `setDraw(id, { instanceCount })` merges into the draw range per frame (absent keys keep their values, like params), so the population breathes without touching the buffer.
56
57
  - `gpu-draw-list.tsx` - `createDrawTarget`: one render target holding an ordered, mutable LIST of draws - two orbiting triangles from different programs sharing one depth buffer (the target owns the depth storage, each pipeline the test/write behavior), a third entry added and removed live via its stable `DrawId`, and `setDrawParams` as the per-object channel.
@@ -0,0 +1,135 @@
1
+ // A chain of targets: a plasma fragment pass feeding a cube pipeline that
2
+ // samples it. Sampler bindings are live dependencies (the model in
3
+ // @solidrt/core/docs/reference/gpu.md): the cube's target is bound to the
4
+ // plasma's through `textures`, so whenever the plasma re-renders, the cube
5
+ // re-renders after it in the same frame, in that order. The app therefore
6
+ // drives only the first pass: the plasma's uTime through the <texture>
7
+ // params prop, as in gpu-shader.tsx. The cube has no uniform of its own
8
+ // and no per-frame code; its view is fixed in the vertex shader, and its
9
+ // faces move because what they sample moves.
10
+ //
11
+ // The plasma is shown beside the cube because that <texture> is where its
12
+ // params prop lives. A source pass that is not on screen is driven the same
13
+ // way from any <texture> of its id. The cube's buffers and index buffer are
14
+ // the gpu-pipeline.tsx mesh with a UV per corner in place of a color.
15
+ import { render, onFrame, createSignal } from "@solidrt/core"
16
+ import { createBuffer, createPipelineTexture, createShaderTexture, glsl } from "@solidrt/core/gpu"
17
+
18
+ // The plasma target's side: what the cube's faces sample.
19
+ let PLASMA_SIZE = 256
20
+ // The cube target's side: what the window shows.
21
+ let CUBE_SIZE = 512
22
+
23
+ // Pass 1: a plasma, the only thing the app animates.
24
+ let PLASMA = glsl`
25
+ uniform float uTime;
26
+ void main() {
27
+ vec2 uv = vUV * 6.0;
28
+ float t = uTime;
29
+ float v = sin(uv.x + t) + sin(uv.y + t * 0.8) + sin((uv.x + uv.y) * 0.7 + t * 1.3)
30
+ + sin(length(uv - 3.0) * 1.5 - t);
31
+ vec3 col = 0.5 + 0.5 * cos(v * 1.2 + vec3(0.0, 2.1, 4.2));
32
+ fragColor = vec4(col, 1.0);
33
+ }
34
+ `
35
+
36
+ // Pass 2: the cube, viewed from one fixed angle. The projection rig and the
37
+ // clip-y flip are gpu-pipeline.tsx's.
38
+ let VERTEX = glsl`
39
+ in vec3 aPos;
40
+ in vec2 aUV;
41
+ out vec2 vTex;
42
+
43
+ void main() {
44
+ float cy = cos(0.7), sy = sin(0.7);
45
+ float cx = cos(0.5), sx = sin(0.5);
46
+ mat3 rotY = mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
47
+ mat3 rotX = mat3(1.0, 0.0, 0.0, 0.0, cx, sx, 0.0, -sx, cx);
48
+ vec3 p = rotX * (rotY * aPos);
49
+ p.z -= 2.5;
50
+ float w = -p.z;
51
+ float f = 2.0;
52
+ float a = 11.0 / 9.0;
53
+ float b = -20.0 / 9.0;
54
+ gl_Position = vec4(p.x * f, -p.y * f, w * a + b, w);
55
+ vTex = aUV;
56
+ }
57
+ `
58
+
59
+ // The sampler is the chain: uMap is bound to the plasma target below.
60
+ let FRAGMENT = glsl`
61
+ in vec2 vTex;
62
+ uniform sampler2D uMap;
63
+ void main() {
64
+ fragColor = texture(uMap, vTex);
65
+ }
66
+ `
67
+
68
+ // Interleaved [pos vec3, uv vec2]: 24 vertices, 4 per face, each face the
69
+ // whole plasma once. Every face winds counter-clockwise seen from outside.
70
+ function cubeVertices(): Float32Array {
71
+ type Vec3 = [number, number, number]
72
+ let verts: number[] = []
73
+ let quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3) => {
74
+ let uvs = [[0, 1], [1, 1], [1, 0], [0, 0]]
75
+ for (let [i, p] of [a, b, c, d].entries()) verts.push(p[0], p[1], p[2], uvs[i]![0]!, uvs[i]![1]!)
76
+ }
77
+ let s = 0.5
78
+ quad([-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]) // front
79
+ quad([s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]) // back
80
+ quad([s, -s, s], [s, -s, -s], [s, s, -s], [s, s, s]) // right
81
+ quad([-s, -s, -s], [-s, -s, s], [-s, s, s], [-s, s, -s]) // left
82
+ quad([-s, s, s], [s, s, s], [s, s, -s], [-s, s, -s]) // top
83
+ quad([-s, -s, -s], [s, -s, -s], [s, -s, s], [-s, -s, s]) // bottom
84
+ return new Float32Array(verts)
85
+ }
86
+
87
+ // Two triangles per face over its 4 shared vertices: 36 uint16 indices.
88
+ function cubeIndices(): Uint16Array {
89
+ let indices: number[] = []
90
+ for (let face = 0; face < 6; face++) {
91
+ let v = face * 4
92
+ indices.push(v, v + 1, v + 2, v, v + 2, v + 3)
93
+ }
94
+ return new Uint16Array(indices)
95
+ }
96
+
97
+ function App() {
98
+ let plasmaId = createShaderTexture(PLASMA, PLASMA_SIZE, PLASMA_SIZE, { uTime: 0 }, { label: "plasma" })
99
+ let bufferId = createBuffer(cubeVertices(), { label: "cube-verts" })
100
+ let indexId = createBuffer(cubeIndices(), { label: "cube-indices" })
101
+ // No params: nothing of the cube's is ever written. Its one input is the
102
+ // plasma target, and that binding is what re-renders it.
103
+ let cubeId = createPipelineTexture(VERTEX, FRAGMENT, CUBE_SIZE, CUBE_SIZE, null, {
104
+ label: "cube",
105
+ textures: { uMap: plasmaId },
106
+ buffers: [
107
+ {
108
+ attributes: [
109
+ { name: "aPos", format: "float32x3" },
110
+ { name: "aUV", format: "float32x2" },
111
+ ],
112
+ buffer: bufferId,
113
+ },
114
+ ],
115
+ indexBuffer: indexId,
116
+ indexFormat: "uint16",
117
+ depth: true,
118
+ cull: "back",
119
+ clearColor: [0.08, 0.08, 0.12, 1],
120
+ })
121
+ let [time, setTime] = createSignal(0)
122
+ onFrame((tick) => setTime(tick / 1000))
123
+
124
+ return (
125
+ <window alignItems="center" justifyContent="center" flexDirection="row" gap={24}>
126
+ <texture src={cubeId} width={CUBE_SIZE} height={CUBE_SIZE} />
127
+ <view flexDirection="column" alignItems="center" gap={8}>
128
+ <texture src={plasmaId} params={{ uTime: time() }} width={PLASMA_SIZE} height={PLASMA_SIZE} />
129
+ <text fontSize={14} color="#888">the plasma pass, the only one driven</text>
130
+ </view>
131
+ </window>
132
+ )
133
+ }
134
+
135
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.70",
3
+ "version": "0.0.71",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -33,8 +33,8 @@
33
33
  "AGENTS.md"
34
34
  ],
35
35
  "devDependencies": {
36
- "@solidrt/flux-types": "0.0.70",
37
- "@solidrt/test": "0.0.70"
36
+ "@solidrt/flux-types": "0.0.71",
37
+ "@solidrt/test": "0.0.71"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@solidjs/signals": "2.0.0-rc.13",
package/src/core.ts CHANGED
@@ -391,15 +391,18 @@ export function prepareText(text: string, options?: tree.MeasureTextOptions): tr
391
391
  }
392
392
 
393
393
  /**
394
- * Makes the glyph cells of printable ASCII for these font styles ahead of
395
- * their first use, off the frame: call it at startup with the type scale
396
- * the app draws in (the components' Window does it for the theme's), so a
397
- * screen's first paint in any of them never waits for cells. Glyphs outside
398
- * ASCII are made at first use. Each entry names a font as a <text> would;
399
- * line height and maxLines are ignored.
394
+ * Makes glyph cells ahead of their first use, off the frame: call it at
395
+ * startup with the type scale the app draws in (the components' Window
396
+ * does it for the theme's), so a screen's first paint in any of them never
397
+ * waits for cells. Each entry names a font as a <text> would (line height
398
+ * and maxLines are ignored) and warms its printable ASCII; an entry with a
399
+ * `text` also warms the glyphs that string shapes to in the font (a
400
+ * ligature, a curly quote, a letter beyond ASCII), so a headline or a
401
+ * label the app knows it will draw is warm too. Anything else is made at
402
+ * first use.
400
403
  */
401
- export function warmText(styles: tree.MeasureTextOptions[]): void {
402
- tree.warmText(styles)
404
+ export function warmText(entries: tree.WarmTextEntry[]): void {
405
+ tree.warmText(entries)
403
406
  }
404
407
 
405
408
  export type TextRenderingOptions = tree.TextRenderingOptions
package/src/gpu.ts CHANGED
@@ -753,6 +753,128 @@ export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferVie
753
753
  gpu.writeBuffer(id, toUint8(data), byteOffset)
754
754
  }
755
755
 
756
+ /**
757
+ * A stream of fixed-size records with a GPU buffer, a CPU mirror and a
758
+ * dirty range: the publish model shared by every JS-stepped population
759
+ * (a 2d sprite layer's records, a 3d mesh's instance streams). Write the
760
+ * mirror, `mark` the records written, then `publish` at the flush: the
761
+ * dirty range goes out as one `writeBuffer` at its byte offset, or the
762
+ * drawn prefix whole through the write lease when the buffer carries an
763
+ * instance order (the engine gathers the copy into key order, and a byte
764
+ * range has no stable position under a permutation). Growth replaces the
765
+ * buffer and the mirror (never a resize) and marks what the mirror held,
766
+ * so the next publish fills the replacement; the old buffer is the
767
+ * caller's to destroy once nothing binds it. Read `buffer` and `bytes` at
768
+ * use time: both are replaced by growth.
769
+ */
770
+ export type RecordStream = {
771
+ /** The GPU buffer the records publish to; replaced by `grow`. */
772
+ readonly buffer: gpu.BufferId
773
+ /** The mirror, `capacity * stride` bytes; replaced by `grow`. View it as
774
+ * the record layout wants (a Float32Array over it for an all-float
775
+ * record). */
776
+ readonly bytes: Uint8Array<ArrayBuffer>
777
+ /** Bytes per record. */
778
+ readonly stride: number
779
+ /** Records the mirror and the buffer hold. */
780
+ readonly capacity: number
781
+ /** Whether any record is marked since the last publish. */
782
+ readonly dirty: boolean
783
+ /** Extend the dirty range over records [first, last). */
784
+ mark(first: number, last: number): void
785
+ /** Send what is dirty and clear the mark. `whole`: the first `limit`
786
+ * records go out through the lease regardless of the range (the
787
+ * ordered form, and a replacement buffer's first fill); otherwise the
788
+ * dirty range, clipped to `limit` records (the live sprites, or the
789
+ * capacity when records are written ahead of their count). Nothing is
790
+ * sent for an empty range or a zero `limit`. */
791
+ publish(whole: boolean, limit: number): void
792
+ /** Replace the buffer and the mirror with room for `capacity` records,
793
+ * the old contents copied over and marked whole; returns the OLD buffer
794
+ * for the caller to destroy once its draws point at the new one. */
795
+ grow(capacity: number): gpu.BufferId
796
+ /** Free the buffer. Safe to call more than once. */
797
+ destroy(): void
798
+ }
799
+
800
+ /**
801
+ * Creates a record stream of `capacity` records of `stride` bytes (see
802
+ * {@link RecordStream}): a sized, unwritten GPU buffer and a zeroed
803
+ * mirror. Freed with the reactive owner like a buffer (opt out with
804
+ * `{ autoFree: false }` and call `destroy`).
805
+ */
806
+ export function createRecordStream(stride: number, capacity: number, opts?: CreateOptions): RecordStream {
807
+ if (!(Number.isInteger(stride) && stride > 0)) throw new Error("createRecordStream: stride must be a positive integer, got " + stride)
808
+ if (!(Number.isInteger(capacity) && capacity > 0)) throw new Error("createRecordStream: capacity must be a positive integer, got " + capacity)
809
+ let buffer = createBuffer(capacity * stride, { label: opts?.label, autoFree: false })
810
+ let bytes = new Uint8Array(capacity * stride)
811
+ let dirty: [number, number] | null = null
812
+ let destroyed = false
813
+ let stream: RecordStream = {
814
+ get buffer() {
815
+ return buffer
816
+ },
817
+ get bytes() {
818
+ return bytes
819
+ },
820
+ stride,
821
+ get capacity() {
822
+ return bytes.length / stride
823
+ },
824
+ get dirty() {
825
+ return dirty !== null
826
+ },
827
+ mark(first, last) {
828
+ let capacity = bytes.length / stride
829
+ if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last > capacity || first > last) {
830
+ throw new Error("RecordStream.mark: range [" + first + ", " + last + ") is outside the stream's " + capacity + " records")
831
+ }
832
+ if (first === last) return
833
+ if (dirty === null) dirty = [first, last]
834
+ else {
835
+ if (first < dirty[0]) dirty[0] = first
836
+ if (last > dirty[1]) dirty[1] = last
837
+ }
838
+ },
839
+ publish(whole, limit) {
840
+ let range = dirty
841
+ dirty = null
842
+ if (whole) {
843
+ let byteLength = Math.min(limit, bytes.length / stride) * stride
844
+ if (byteLength <= 0) return
845
+ let out = beginBufferWrite(buffer)
846
+ new Uint8Array(out.buffer, 0, byteLength).set(bytes.subarray(0, byteLength))
847
+ gpu.endBufferWrite(buffer, byteLength)
848
+ return
849
+ }
850
+ if (range === null) return
851
+ let lo = range[0]
852
+ let hi = Math.min(range[1], limit)
853
+ if (hi > lo) writeBuffer(buffer, bytes.subarray(lo * stride, hi * stride), lo * stride)
854
+ },
855
+ grow(next) {
856
+ let previous = bytes.length / stride
857
+ if (!(Number.isInteger(next) && next > previous)) {
858
+ throw new Error("RecordStream.grow: capacity must be an integer above the current " + previous + ", got " + next)
859
+ }
860
+ let old = buffer
861
+ buffer = createBuffer(next * stride, { label: opts?.label, autoFree: false })
862
+ let grown = new Uint8Array(next * stride)
863
+ grown.set(bytes)
864
+ bytes = grown
865
+ dirty = [0, previous]
866
+ return old
867
+ },
868
+ destroy() {
869
+ if (destroyed) return
870
+ destroyed = true
871
+ gpu.destroyBuffer(buffer)
872
+ },
873
+ }
874
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => stream.destroy())
875
+ return stream
876
+ }
877
+
756
878
  /**
757
879
  * Hands an ordered instance buffer's full record set to the engine - the
758
880
  * write-once form: the engine keeps the one CPU copy and sorts,
package/src/index.ts CHANGED
@@ -94,7 +94,7 @@ export type {
94
94
  Color,
95
95
  Pct,
96
96
  } from "./types"
97
- export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
97
+ export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit, WarmTextEntry } from "flux:rendertree"
98
98
 
99
99
  // A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
100
100
  // half the element box. Keeps percentages a first-class branded value rather