@solidrt/core 0.0.39 → 0.0.41

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/src/gpu.ts CHANGED
@@ -7,6 +7,40 @@
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.
24
+ //
25
+ // The pixel contract. Three facts hold for every texture and target:
26
+ //
27
+ // - Clip space is y-down. `gl_Position` y = -1 is the top of the target, +1
28
+ // the bottom (GL's row 0 is clip y = -1, and Impeller samples row 0 as the
29
+ // top). A vertex stage carrying camera-up geometry must negate y, or fold
30
+ // the flip into its projection, or it draws upside down: Vulkan's
31
+ // convention, not desktop GL's. The fragment path absorbs the same flip
32
+ // already, so `vUV` is 0..1 with top-left origin and a fragment-only shader
33
+ // never sees it.
34
+ // - Color is premultiplied alpha. A target's RGB is expected already
35
+ // multiplied by its A - `vec4(rgb * a, a)`, not `vec4(rgb, a)`, which
36
+ // composites as opaque. That is what Impeller composites and what
37
+ // `<texture blendMode>` blends; `clearColor` is premultiplied too, so the
38
+ // default transparent black needs no thought.
39
+ // - Values are non-linear RGBA8, with no color-space concept. Every texture
40
+ // and target holds 8-bit RGBA UNORM exactly as written; nothing converts to
41
+ // or from linear light. `filter: "linear"` averages and `blend: "add"`
42
+ // accumulates non-linear values - the usual approximation, stated so
43
+ // shaders written today stay correct if a format vocabulary arrives.
10
44
 
11
45
  import { createEffect, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
12
46
  import * as gpu from "flux:gpu"
@@ -16,7 +50,21 @@ import * as gpu from "flux:gpu"
16
50
  // signal changes inside a long-lived component, handed across owners, ...).
17
51
  // Without it, each rebuild would stack another onCleanup on the component
18
52
  // owner: a leak until unmount, then a double-free against manual destroys.
19
- export type CreateOptions = { manual?: boolean }
53
+ // `label` is a free-form debug name (WebGPU's label): surfaced by the dev
54
+ // tooling's GPU inventory and engine log messages, never interpreted, kept
55
+ // across id-stable resizes.
56
+ export type CreateOptions = { manual?: boolean; label?: string }
57
+
58
+ // Sampling options every texture-producing create* helper accepts, applied at
59
+ // creation as a property of the texture id (there is no set-sampler-later).
60
+ export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode }
61
+ export type { FilterMode, WrapMode } from "flux:gpu"
62
+
63
+ // The branded id types, one per id space (see flux:gpu): plain numbers at
64
+ // runtime, distinct types to the checker, so a cross-space slip like
65
+ // destroyBuffer(textureId) fails to compile. Exported so apps can annotate
66
+ // storage (`let ids: TextureId[]`).
67
+ export type { BufferId, ProgramId, RenderPipelineId, ShaderStageId, TextureId } from "flux:gpu"
20
68
 
21
69
  // Re-exported so callers that depend on @solidrt/core -- like @solidrt/components
22
70
  // -- need not import flux directly: destroyTexture for the manual-cleanup path
@@ -38,23 +86,70 @@ export {
38
86
  uploadTexture,
39
87
  } from "flux:gpu"
40
88
 
41
- // Pipeline plumbing re-exported raw: setDrawCount re-renders a pipeline after
42
- // its buffer gained or lost dynamic geometry; destroyBuffer is the manual
43
- // cleanup path for buffers created outside a reactive scope.
44
- export { destroyBuffer, setDrawCount } from "flux:gpu"
45
- export type { Topology, VertexAttribute } from "flux:gpu"
89
+ // Pipeline plumbing re-exported raw: setDraw re-renders a pipeline with an
90
+ // updated draw range (vertexCount after its buffer gained or lost dynamic
91
+ // geometry, firstVertex for a different window of a shared buffer,
92
+ // instanceCount for an instanced population; absent keys keep their current
93
+ // value, like params); destroyBuffer is the manual
94
+ // cleanup path for buffers created outside a reactive scope. renderTarget is
95
+ // the explicit render verb for `render: "manual"` targets - targets whose
96
+ // pass is state (accumulation, feedback) rather than a pure function of its
97
+ // inputs, which the runtime therefore never renders on its own; the app
98
+ // steps them, usually from onFrame. (`render: "manual"` is the render mode;
99
+ // the unrelated `manual: true` create option is the lifetime opt-out above.)
100
+ // copyTexture overwrites a manual target with another texture's pixels
101
+ // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
102
+ // ping-pong buffer, reset state to a known image.
103
+ export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
104
+ export type { BlendMode, DrawRange, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
105
+
106
+ // The device ceilings (max texture/target size, sampler inputs per pass,
107
+ // vertex attributes per pipeline), queried once at startup. Creates and binds
108
+ // validate against them and throw naming the limit; read these to size within
109
+ // the device instead (e.g. clamp a supersampled target to maxTextureSize).
110
+ export { limits } from "flux:gpu"
46
111
 
47
112
  // The raw shading layer, re-exported as-is - no reactive wrapper, the app
48
113
  // owns these lifetimes. compileShader compiles one stage from complete GLSL
49
114
  // ES (or with the standard header via { header: true }); linkProgram links a
50
- // vertex and a fragment stage into a program handle that backs any number of
51
- // createShaderTarget calls (and compiles nothing per target); destroyShader /
52
- // destroyProgram free by id space, either order safe against live targets.
53
- // createShader/createPipeline remain the fused conveniences on top.
54
- export { compileShader, destroyProgram, destroyShader, linkProgram } from "flux:gpu"
115
+ // vertex and a fragment stage into a program handle; createRenderPipeline
116
+ // pairs a program with draw state (vertex layout, topology, blend, depth -
117
+ // how it draws) into a pipeline handle that backs any number of
118
+ // createShaderTarget calls (and compiles nothing per pipeline or target);
119
+ // destroyShader / destroyProgram / destroyRenderPipeline free by id space,
120
+ // any order safe against live users. createShaderTexture/createPipelineTexture
121
+ // remain the fused conveniences on top.
122
+ export {
123
+ compileShader,
124
+ createRenderPipeline,
125
+ destroyProgram,
126
+ destroyRenderPipeline,
127
+ destroyShader,
128
+ linkProgram,
129
+ } from "flux:gpu"
130
+
131
+ /**
132
+ * Tags an inline GLSL source, returning it unchanged. Shaders small enough to
133
+ * belong beside the code that uses them stay in the file; the tag is what makes
134
+ * them legible there, because editors highlight GLSL inside a template literal
135
+ * only when a known tag marks it (the name matters - `glsl` is the one the
136
+ * grammars look for).
137
+ *
138
+ * Interpolated values are stringified verbatim, with no GLSL-aware formatting:
139
+ * `${2}` splices in the int literal `2`, which will not assign to a float. Pass
140
+ * anything that varies as a uniform instead of building it into the source.
141
+ *
142
+ * Raw semantics, so backslashes reach the compiler as written: the GLSL
143
+ * preprocessor continues a line with a trailing `\`, which a cooked template
144
+ * would reject as an invalid escape and silently pass through as `undefined`.
145
+ */
146
+ export let glsl = String.raw
55
147
 
56
148
  // captureSnapshot renders a node to a texture and readTexture reads any
57
- // texture's bytes back. Re-exported raw (no reactive auto-cleanup wrapper):
149
+ // texture's bytes back. A laid-out node captures its layout box; a `d-*` node
150
+ // captures its painted box - its own w/h when set, else the nearest laid-out
151
+ // ancestor's box, its x/y offset mapped to the texture origin. Re-exported raw
152
+ // (no reactive auto-cleanup wrapper):
58
153
  // captureSnapshot resolves asynchronously, by which point the reactive owner is
59
154
  // no longer current, so the caller owns the returned id and frees it with
60
155
  // destroyTexture (as with any texture created after an await).
@@ -81,8 +176,13 @@ export { captureSnapshot, readTexture } from "flux:gpu"
81
176
  * yourself. Pass `{ manual: true }` to skip the auto-free and own the
82
177
  * disposal yourself even inside a reactive scope.
83
178
  */
84
- export function createTexture(data: Uint8Array, width: number, height: number, opts?: CreateOptions): number {
85
- let id = gpu.createTexture(data, width, height)
179
+ export function createTexture(
180
+ data: Uint8Array,
181
+ width: number,
182
+ height: number,
183
+ opts?: CreateOptions & SamplerOptions,
184
+ ): gpu.TextureId {
185
+ let id = gpu.createTexture(data, width, height, opts)
86
186
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
87
187
  return id
88
188
  }
@@ -96,8 +196,13 @@ export function createTexture(data: Uint8Array, width: number, height: number, o
96
196
  * outside a reactive scope you must call `destroyTexture` (from flux:gpu)
97
197
  * yourself.
98
198
  */
99
- export function createMutableTexture(data: Uint8Array, width: number, height: number, opts?: CreateOptions): number {
100
- let id = gpu.createMutableTexture(data, width, height)
199
+ export function createMutableTexture(
200
+ data: Uint8Array,
201
+ width: number,
202
+ height: number,
203
+ opts?: CreateOptions & SamplerOptions,
204
+ ): gpu.TextureId {
205
+ let id = gpu.createMutableTexture(data, width, height, opts)
101
206
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
102
207
  return id
103
208
  }
@@ -105,78 +210,122 @@ export function createMutableTexture(data: Uint8Array, width: number, height: nu
105
210
  /**
106
211
  * Compiles a GLSL ES 3.00 fragment shader and renders it into a texture,
107
212
  * returning the texture id (usable anywhere a normal texture id is, e.g.
108
- * `<texture src>`). The fragment body may reference `vUV` (0..1, top-left
109
- * origin), `iResolution`, `iTime`, and any `uniform float` it declares; drive
110
- * their values with `<texture src={id} params={{...}} />` (preferred) or, when
111
- * there is no `<texture>` element for it, imperatively with `setShaderParams`.
112
- * `textures` binds each declared `uniform sampler2D` to an existing texture id
113
- * (e.g. a camera or decoded image) so the shader can read it; those inputs are
114
- * re-sampled on every params update, so live sources stay current. Frees the
213
+ * `<texture src>`) - hence the name: what comes back is a texture, not a
214
+ * shader object. The fragment body may reference `vUV` (0..1, top-left
215
+ * origin), `iResolution`, and any uniform it declares (`float`/`int`
216
+ * scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
217
+ * array); drive their values with `<texture src={id} params={{...}} />`
218
+ * (preferred) or, when there is no `<texture>` element for it, imperatively
219
+ * with `setShaderParams`. `params` is its own argument - it seeds the same
220
+ * live channel those two drive - and takes `null` (or nothing) for a shader
221
+ * without uniforms. A time-driven shader declares its own time uniform
222
+ * (`uniform float uTime;`) and the app drives it like any other value.
223
+ * `opts.textures` binds each declared `uniform sampler2D` to an existing texture id
224
+ * (e.g. a camera or decoded image, or another shader/pipeline target) so the
225
+ * shader can read it; bound inputs are live dependencies, so the shader
226
+ * re-renders whenever a source changes - including a sampled target
227
+ * re-rendering, transitively through chains. Frees the
115
228
  * texture and shader program when the reactive owner is disposed (opt out
116
229
  * with `{ manual: true }`); create outside any reactive scope for
117
230
  * app-lifetime shaders. For a shader whose source or inputs change
118
- * reactively, use {@link createShaderMemo} instead.
231
+ * reactively, use {@link createShaderTextureMemo} instead.
232
+ *
233
+ * That preamble (`#version 300 es`, precision, `vUV`, `iResolution`,
234
+ * `fragColor`) is injected only into sources that do not declare their own
235
+ * `#version` line, and declares exactly what the runtime provides - nothing
236
+ * app-driven. A source starting with `#version 300 es` compiles exactly
237
+ * as written, so a shader carrying its own uniform names - one ported from
238
+ * elsewhere - runs unchanged here without dropping to compileShader /
239
+ * linkProgram. The built-in vertex stage still supplies `vUV`; declare
240
+ * `in vec2 vUV;` yourself to read it.
119
241
  */
120
- export function createShader(
242
+ export function createShaderTexture(
121
243
  fragmentSrc: string,
122
244
  width: number,
123
245
  height: number,
124
- params?: Record<string, number>,
125
- textures?: Record<string, number>,
126
- opts?: CreateOptions,
127
- ): number {
128
- let id = gpu.createShader(fragmentSrc, width, height, params, textures)
246
+ params?: gpu.ShaderParams | null,
247
+ opts?: CreateOptions & SamplerOptions & { textures?: Record<string, gpu.TextureId> },
248
+ ): gpu.TextureId {
249
+ let id = gpu.createShaderTexture(fragmentSrc, width, height, params, opts)
129
250
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
130
251
  return id
131
252
  }
132
253
 
133
254
  /**
134
- * Creates a render target over a program from `linkProgram` and renders it
135
- * once, returning the texture id (usable anywhere a normal texture id is,
136
- * e.g. `<texture src>`; resize with `setShaderSize`, drive uniforms with
137
- * `<texture params>` or `setShaderParams`). Many targets may share one
138
- * program, and creating a target compiles nothing. The mesh options mirror
139
- * `createPipeline`: a raw-linked program carries its own vertex stage, so a
140
- * fullscreen pass is `{ vertexCount: 3 }` over a covering-triangle vertex
141
- * stage. Frees the target when the reactive owner is disposed (opt out with
142
- * `opts.manual`); the program is yours and outlives it.
255
+ * Creates a render target over a pipeline from `createRenderPipeline` and
256
+ * renders it once, returning the texture id (usable anywhere a normal
257
+ * texture id is, e.g. `<texture src>`; resize with `setShaderSize`, drive
258
+ * uniforms with `<texture params>` or `setShaderParams`). Many targets may
259
+ * share one pipeline, and creating a target compiles nothing. The target
260
+ * brings the per-target half: size, the concrete vertex `buffer` the
261
+ * pipeline's attribute layout describes, the draw range (`vertexCount`
262
+ * defaults to the rest of the buffer from `firstVertex` on, `instanceCount`
263
+ * repeats it as instances told apart by `gl_InstanceID`; a fullscreen pass
264
+ * over an attributeless pipeline is `{ vertexCount: 3 }` with a
265
+ * covering-triangle vertex stage), uniforms, and
266
+ * `clearColor`. Draw state (`attributes`, `topology`, `blend`, `depth`,
267
+ * `depthWrite`) lives on the pipeline and throws here. Frees the target when
268
+ * the reactive owner is disposed (opt out with `opts.manual`); the pipeline
269
+ * is yours and outlives it.
270
+ *
271
+ * `render: "manual"` makes it a manual target: the runtime never renders it
272
+ * (it starts cleared to `clearColor`), only an explicit `renderTarget(id)`
273
+ * does, in call order - which is what legalizes feedback state stepped by
274
+ * the app. `loadOp: "load"` (manual-only, throws otherwise) keeps the
275
+ * previous contents under each draw - single-target accumulation - while
276
+ * the default `"clear"` clears to `clearColor` per render; state that must
277
+ * read its own pixels (decay, blur, simulation) still ping-pongs across two
278
+ * manual targets, and `copyTexture` seeds either shape. Distinct from the
279
+ * `manual` lifetime option: `render` is who renders, `manual` is who frees.
143
280
  */
144
281
  export function createShaderTarget(
145
- program: number,
282
+ pipeline: gpu.RenderPipelineId,
146
283
  width: number,
147
284
  height: number,
285
+ params?: gpu.ShaderParams | null,
148
286
  opts?: {
149
- params?: Record<string, number>
150
- textures?: Record<string, number>
151
- attributes?: gpu.VertexAttribute[]
152
- buffer?: number
153
- topology?: gpu.Topology
154
- vertexCount?: number
155
- depth?: boolean
287
+ textures?: Record<string, gpu.TextureId>
288
+ buffer?: gpu.BufferId
156
289
  clearColor?: [number, number, number, number]
157
- } & CreateOptions,
158
- ): number {
159
- let id = gpu.createShaderTarget(program, width, height, opts)
290
+ render?: "auto" | "manual"
291
+ loadOp?: "clear" | "load"
292
+ } & gpu.DrawRange &
293
+ CreateOptions &
294
+ SamplerOptions,
295
+ ): gpu.TextureId {
296
+ let id = gpu.createShaderTarget(pipeline, width, height, params, opts)
160
297
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
161
298
  return id
162
299
  }
163
300
 
164
- /** The reactive shader description `createShaderMemo` builds from. */
301
+ /** The reactive shader description `createShaderTextureMemo` builds from.
302
+ * Sampling (`filter`/`wrap`) is creation-time state, so changing it rebuilds
303
+ * at a fresh id, like a fragment-source or sampler-binding change. */
165
304
  export type ShaderSpec = {
166
305
  fragmentSrc: string
167
306
  width: number
168
307
  height: number
169
- params?: Record<string, number>
170
- textures?: Record<string, number>
308
+ params?: gpu.ShaderParams
309
+ textures?: Record<string, gpu.TextureId>
310
+ } & SamplerOptions
311
+
312
+ // Shallow name->value equality for params/textures records; treats undefined
313
+ // as the empty record. A param value may be a number or a flat number array
314
+ // (typed uniforms), so arrays compare elementwise.
315
+ function sameValue(a: number | number[] | undefined, b: number | number[] | undefined): boolean {
316
+ if (a === b) return true
317
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
318
+ return a.length === b.length && a.every((v, i) => v === b[i])
171
319
  }
172
320
 
173
- // Shallow name->number equality for params/textures records; treats undefined
174
- // as the empty record.
175
- function sameRecord(a: Record<string, number> | undefined, b: Record<string, number> | undefined): boolean {
321
+ function sameRecord(
322
+ a: Record<string, number | number[]> | undefined,
323
+ b: Record<string, number | number[]> | undefined,
324
+ ): boolean {
176
325
  if (a === b) return true
177
326
  let ka = a ? Object.keys(a) : []
178
327
  let kb = b ? Object.keys(b) : []
179
- return ka.length === kb.length && ka.every(k => a![k] === b![k])
328
+ return ka.length === kb.length && ka.every(k => sameValue(a![k], b![k]))
180
329
  }
181
330
 
182
331
  /**
@@ -191,28 +340,57 @@ function sameRecord(a: Record<string, number> | undefined, b: Record<string, num
191
340
  * so the swap never paints a blank frame. The current id is freed when the
192
341
  * owning scope is disposed. Data textures need no analog: `uploadTexture` and
193
342
  * `resizeTexture` already cover their reactive changes id-stably.
343
+ *
344
+ * `onError` makes a failed rebuild survivable. Without it a shader that does
345
+ * not compile throws from inside the effect, where no caller can catch it;
346
+ * with it the error is handed to you and the last shader that DID compile
347
+ * stays current - id, size, params and accessor all unchanged - so the app
348
+ * keeps drawing the previous frame's shader instead of tearing down. That is
349
+ * the normal case whenever the source is not known-good: a shader editor, live
350
+ * coding, or a dialect ported from elsewhere. The initial compile is not
351
+ * covered: it throws at the call site, where an ordinary try/catch works and
352
+ * there is no previous shader to fall back to.
194
353
  */
195
- export function createShaderMemo(spec: () => ShaderSpec): () => number {
354
+ export function createShaderTextureMemo(
355
+ spec: () => ShaderSpec,
356
+ opts?: { onError?: (error: unknown) => void },
357
+ ): () => gpu.TextureId {
358
+ let make = (s: ShaderSpec) =>
359
+ gpu.createShaderTexture(s.fragmentSrc, s.width, s.height, s.params, { textures: s.textures, filter: s.filter, wrap: s.wrap })
196
360
  let current = untrack(spec)
197
- let currentId = gpu.createShader(current.fragmentSrc, current.width, current.height, current.params, current.textures)
361
+ let currentId = make(current)
198
362
  let [id, setId] = createSignal(currentId)
199
363
  createEffect(spec, next => {
200
- if (next.fragmentSrc === current.fragmentSrc && sameRecord(next.textures, current.textures)) {
201
- // Program and inputs unchanged: mutate in place, the id stays stable.
202
- if (next.width !== current.width || next.height !== current.height) {
203
- gpu.setShaderSize(currentId, next.width, next.height)
204
- }
205
- if (!sameRecord(next.params, current.params) && next.params) {
206
- gpu.setShaderParams(currentId, next.params)
364
+ try {
365
+ if (
366
+ next.fragmentSrc === current.fragmentSrc &&
367
+ sameRecord(next.textures, current.textures) &&
368
+ next.filter === current.filter &&
369
+ next.wrap === current.wrap
370
+ ) {
371
+ // Program and inputs unchanged: mutate in place, the id stays stable.
372
+ if (next.width !== current.width || next.height !== current.height) {
373
+ gpu.setShaderSize(currentId, next.width, next.height)
374
+ }
375
+ if (!sameRecord(next.params, current.params) && next.params) {
376
+ gpu.setShaderParams(currentId, next.params)
377
+ }
378
+ current = next
379
+ return
207
380
  }
381
+ // Compile before touching any state: a throw here must leave `current`,
382
+ // `currentId` and the accessor all still pointing at the last shader
383
+ // that worked, which is what makes onError's keep-last-good real.
384
+ let rebuilt = make(next)
385
+ let old = currentId
208
386
  current = next
209
- return
387
+ currentId = rebuilt
388
+ setId(rebuilt)
389
+ gpu.destroyTexture(old)
390
+ } catch (error) {
391
+ if (!opts?.onError) throw error
392
+ opts.onError(error)
210
393
  }
211
- let old = currentId
212
- current = next
213
- currentId = gpu.createShader(next.fragmentSrc, next.width, next.height, next.params, next.textures)
214
- setId(currentId)
215
- gpu.destroyTexture(old)
216
394
  })
217
395
  if (getOwner()) onCleanup(() => gpu.destroyTexture(currentId))
218
396
  return id
@@ -229,35 +407,56 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
229
407
  /**
230
408
  * Compiles a GLSL ES 3.00 vertex+fragment pipeline and renders it into a
231
409
  * texture, returning the texture id (usable anywhere a normal texture id is,
232
- * e.g. `<texture src>`). Unlike `createShader` the vertex stage is yours:
410
+ * e.g. `<texture src>`) - named, like `createShaderTexture`, for what comes
411
+ * back. Unlike `createShaderTexture` the vertex stage is yours:
233
412
  * declare `in` attributes matching `opts.attributes` (one interleaved vertex
234
413
  * in `opts.buffer`, a {@link createBuffer} id) and your own varyings toward
235
- * the fragment stage. Both sources may reference `iResolution`/`iTime` and any
236
- * `uniform float` they declare; drive values with `<texture src={id}
237
- * params={{...}} />` or `setShaderParams`, exactly like a fragment shader.
414
+ * the fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
415
+ * row of the target and +1 the bottom, so camera-up geometry must negate y
416
+ * (or fold the flip into its projection) to display up. Both sources may
417
+ * reference `iResolution` and any uniform they declare (`float`/`int`
418
+ * scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
419
+ * array); drive values with `<texture src={id} params={{...}} />` or
420
+ * `setShaderParams`, exactly like a fragment shader.
238
421
  * `opts.depth` attaches a private depth buffer (cleared + tested per render);
239
- * `opts.vertexCount` defaults to the whole buffer and can be changed later
240
- * with `setDrawCount`. Frees the texture and GL program when the reactive
422
+ * `opts.depthWrite: false` (requires depth) keeps the test but stops the
423
+ * draw from writing depth. `opts.blend: "add"` makes the draw accumulate
424
+ * overlapping geometry additively (order-independent, no sorting) instead of
425
+ * overwriting; a depth-tested additive pass is `{ depth: true, blend: "add",
426
+ * depthWrite: false }` - each option only does what it says, neither implies
427
+ * the other. The draw range (`firstVertex`, `vertexCount`, `instanceCount` -
428
+ * see DrawRange) defaults to the whole buffer drawn once and can be changed
429
+ * later with `setDraw`; `instanceCount` is the standard answer to particles
430
+ * and repeated meshes, N copies of the range told apart by `gl_InstanceID`
431
+ * in the vertex stage. `opts.render: "manual"` and
432
+ * `opts.loadOp` behave exactly as on {@link createShaderTarget}: step the
433
+ * target with `renderTarget(id)`, and `loadOp: "load"` (manual-only) keeps
434
+ * the previous contents under each draw. Frees the texture and GL program when the reactive
241
435
  * owner is disposed (opt out with `opts.manual`); create outside any reactive
242
436
  * scope for app-lifetime pipelines.
243
437
  */
244
- export function createPipeline(
438
+ export function createPipelineTexture(
245
439
  vertexSrc: string,
246
440
  fragmentSrc: string,
247
441
  width: number,
248
442
  height: number,
443
+ params?: gpu.ShaderParams | null,
249
444
  opts?: {
250
- params?: Record<string, number>
251
- textures?: Record<string, number>
445
+ textures?: Record<string, gpu.TextureId>
252
446
  attributes?: gpu.VertexAttribute[]
253
- buffer?: number
447
+ buffer?: gpu.BufferId
254
448
  topology?: gpu.Topology
255
- vertexCount?: number
256
449
  depth?: boolean
450
+ depthWrite?: boolean
451
+ blend?: gpu.BlendMode
257
452
  clearColor?: [number, number, number, number]
258
- } & CreateOptions,
259
- ): number {
260
- let id = gpu.createPipeline(vertexSrc, fragmentSrc, width, height, opts)
453
+ render?: "auto" | "manual"
454
+ loadOp?: "clear" | "load"
455
+ } & gpu.DrawRange &
456
+ CreateOptions &
457
+ SamplerOptions,
458
+ ): gpu.TextureId {
459
+ let id = gpu.createPipelineTexture(vertexSrc, fragmentSrc, width, height, params, opts)
261
460
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
262
461
  return id
263
462
  }
@@ -269,10 +468,10 @@ export function createPipeline(
269
468
  * creation, so reserve room up front for dynamic geometry. Freed automatically
270
469
  * when the reactive owner is disposed (opt out with `{ manual: true }`);
271
470
  * created outside a reactive scope you must call `destroyBuffer` yourself.
272
- * Destroy pipelines before their buffer.
471
+ * (Destruction order relative to pipelines does not matter.)
273
472
  */
274
- export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateOptions): number {
275
- let id = gpu.createBuffer(toUint8(data))
473
+ export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateOptions): gpu.BufferId {
474
+ let id = gpu.createBuffer(toUint8(data), opts)
276
475
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyBuffer(id))
277
476
  return id
278
477
  }
@@ -282,6 +481,6 @@ export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateO
282
481
  * pipeline drawing from the buffer re-renders with its last-applied params,
283
482
  * so geometry-only changes reach the screen without a params update.
284
483
  */
285
- export function writeBuffer(id: number, data: ArrayBuffer | ArrayBufferView, byteOffset?: number): void {
484
+ export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferView, byteOffset?: number): void {
286
485
  gpu.writeBuffer(id, toUint8(data), byteOffset)
287
486
  }
package/src/image.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // CPU image codec plus the reactive load-and-upload convenience. decodeImage is
2
2
  // the raw primitive (no GPU involved); createImage is the owner-aware layer on
3
3
  // top that fetches/decodes/uploads for you and swaps the texture when the source
4
- // changes - the same relationship createTexture/createShader have to flux:gpu.
4
+ // changes - the same relationship createTexture/createShaderTexture have to
5
+ // flux:gpu.
5
6
 
6
7
  import { createMemo, onCleanup } from "@solidjs/signals"
7
- import { createTexture, destroyTexture } from "./gpu"
8
+ import { createTexture, destroyTexture, type TextureId } from "./gpu"
8
9
 
9
10
  export type DecodedImage = {
10
11
  data: Uint8Array
@@ -32,13 +33,13 @@ export type ImageSource = string | Uint8Array
32
33
  // Uint8Array sources bypass all of this: no key, per-mount texture.
33
34
  type ImageEntry = {
34
35
  refs: number
35
- texture: number
36
- promise: Promise<number>
36
+ texture: TextureId | undefined
37
+ promise: Promise<TextureId>
37
38
  }
38
39
 
39
40
  let imageCache = new Map<string, ImageEntry>()
40
41
 
41
- async function loadImage(url: string): Promise<number> {
42
+ async function loadImage(url: string): Promise<TextureId> {
42
43
  // Images are assets: cache to disk, no freshness. Use a versioned URL (or
43
44
  // fetch + decodeImage manually) when a URL's content must be re-checked.
44
45
  let res = await fetch(url, { cache: "force-cache" })
@@ -56,7 +57,7 @@ async function loadImage(url: string): Promise<number> {
56
57
  function acquireImage(url: string): ImageEntry {
57
58
  let entry = imageCache.get(url)
58
59
  if (!entry) {
59
- let e: ImageEntry = { refs: 0, texture: -1, promise: undefined as never }
60
+ let e: ImageEntry = { refs: 0, texture: undefined, promise: undefined as never }
60
61
  e.promise = loadImage(url).then(
61
62
  id => {
62
63
  // Everyone released while the load was in flight: nothing owns the
@@ -91,7 +92,7 @@ function releaseImage(url: string): void {
91
92
  if (!entry) return
92
93
  entry.refs--
93
94
  if (entry.refs > 0) return
94
- if (entry.texture >= 0) {
95
+ if (entry.texture !== undefined) {
95
96
  destroyTexture(entry.texture)
96
97
  imageCache.delete(url)
97
98
  }
@@ -122,10 +123,10 @@ function releaseImage(url: string): void {
122
123
  * `createImage` earns its async only for a fetched string URL or a reactive
123
124
  * source.
124
125
  */
125
- export function createImage(src: ImageSource | (() => ImageSource)): () => number {
126
+ export function createImage(src: ImageSource | (() => ImageSource)): () => TextureId {
126
127
  let getSrc = typeof src === "function" ? src : () => src
127
128
 
128
- return createMemo<number>(async () => {
129
+ return createMemo<TextureId>(async () => {
129
130
  let source = getSrc()
130
131
 
131
132
  if (typeof source === "string") {
@@ -138,9 +139,9 @@ export function createImage(src: ImageSource | (() => ImageSource)): () => numbe
138
139
  }
139
140
 
140
141
  // Byte sources decode and upload synchronously; this run owns the texture.
141
- let holder = { id: -1 }
142
+ let holder: { id: TextureId | undefined } = { id: undefined }
142
143
  onCleanup(() => {
143
- if (holder.id >= 0) destroyTexture(holder.id)
144
+ if (holder.id !== undefined) destroyTexture(holder.id)
144
145
  })
145
146
  let decoded: DecodedImage
146
147
  try {
package/src/index.ts CHANGED
@@ -13,8 +13,11 @@ export type { GamepadState } from "./gamepad"
13
13
  export { capabilities } from "./capabilities"
14
14
  export type { Capabilities, WindowSizeClass } from "./capabilities"
15
15
  export { createTexture } from "./gpu"
16
+ export type { TextureId } from "./gpu"
16
17
  export { createImage, decodeImage } from "./image"
17
18
  export type { DecodedImage, ImageSource } from "./image"
19
+ export { parseSvg, svg } from "./svg"
20
+ export type { SvgDocument, SvgDraw } from "./svg"
18
21
  export { createScroll } from "./scroll"
19
22
  export type { Scroll, ScrollAxis, ScrollOffset, ScrollOptions } from "./scroll"
20
23
  export type {
package/src/renderer.ts CHANGED
@@ -129,21 +129,22 @@ export function scanForOrphans(now: number): void {
129
129
  }
130
130
 
131
131
  // A property the native tree rejected must not take down the reactive system:
132
- // a typo'd or not-yet-implemented prop poisons only itself. Warn once per
133
- // element kind + property with a stack (the dev server remaps its frames to
134
- // the .tsx source), then ignore further writes of the same pair.
135
- let warnedUnknownProps = new Set<string>()
132
+ // a typo'd, not-yet-implemented, or detached-only prop poisons only itself.
133
+ // Warn once per element kind + property with a stack (the dev server remaps
134
+ // its frames to the .tsx source), then ignore further writes of the same pair.
135
+ let warnedRejectedProps = new Set<string>()
136
136
 
137
137
  function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
138
138
  try {
139
139
  tree.setProperty(node.id, name, value)
140
140
  } catch (e) {
141
- if (!String(e).includes("unknown property")) throw e
141
+ let message = String(e)
142
+ if (!message.includes("unknown property") && !message.includes("detached-only")) throw e
142
143
  let key = node.elementType + "." + name
143
- if (warnedUnknownProps.has(key)) return
144
- warnedUnknownProps.add(key)
144
+ if (warnedRejectedProps.has(key)) return
145
+ warnedRejectedProps.add(key)
145
146
  let stack = new Error().stack ?? ""
146
- console.warn(`Ignoring unknown property '${name}' on <${node.elementType}>\n${stack}`)
147
+ console.warn(`Ignoring property '${name}' on <${node.elementType}>: ${message}\n${stack}`)
147
148
  }
148
149
  }
149
150